├── .gitignore ├── .travis.yml ├── lib ├── cradle │ ├── database │ │ ├── mango.js │ │ ├── changes.js │ │ ├── index.js │ │ ├── attachments.js │ │ ├── views.js │ │ └── documents.js │ ├── errors.js │ ├── cache.js │ └── response.js └── cradle.js ├── CHANGELOG.md ├── test ├── helpers │ ├── macros.js │ └── seed.js ├── raw-request-test.js ├── errors-test.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 └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | test/fixtures/README.md 4 | test/fixtures/not-found.txt -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_install: 3 | - curl --location http://git.io/1OcIZA | bash -s 4 | services: 5 | - couchdb 6 | node_js: 7 | - "0.10" 8 | - "0.11" 9 | - "0.12" 10 | - "4.0" 11 | - "4.1" 12 | - "stable" 13 | -------------------------------------------------------------------------------- /lib/cradle/database/mango.js: -------------------------------------------------------------------------------- 1 | Database = require('./index').Database; 2 | 3 | Database.prototype.find = function(options, callback) { 4 | return this.query({ 5 | method: 'POST', 6 | path: "/_find", 7 | body: options 8 | }, callback); 9 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 0.7.0 (2016-01-16) 3 | 4 | ### Features 5 | 6 | * support self signed ca certification ([1f46c41](https://github.com/flatiron/cradle/commit/1f46c41667d31f240e4f4e2ce170607cb4442907)) 7 | * options.auth passes straight through to request library ([599eaa2](https://github.com/flatiron/cradle/commit/599eaa2078e06183ba38db38e07156b145700ff0)) 8 | * added logic to allow developers to utilize full request API ([5e0b9ef](https://github.com/flatiron/cradle/commit/5e0b9ef689387a2277ab1ac97beda1e3199b3e54)) 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/cradle/errors.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | var util = require('util'); 4 | 5 | // create custom Error object for better callback(err, ...) support 6 | // accepts an JSON object from CouchDB's REST errors 7 | function CouchError (err) { 8 | // ensure proper stack trace 9 | Error.call(this); 10 | Error.captureStackTrace(this, this.constructor); 11 | 12 | this.name = this.constructor.name; 13 | this.message = err.error + ': ' + err.reason; 14 | 15 | // add properties from CouchDB error response to Error object 16 | for (var k in err) { 17 | if (err.hasOwnProperty(k)) { 18 | this[k] = err[k]; 19 | } 20 | } 21 | this.headers = err.headers; 22 | } 23 | // CouchError instanceof Error 24 | util.inherits(CouchError, Error); 25 | 26 | 27 | // export 28 | this.CouchError = CouchError; 29 | -------------------------------------------------------------------------------- /test/raw-request-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'), 2 | assert = require('assert'), 3 | events = require('events'), 4 | vows = require('vows'), 5 | sinon = require('sinon'), 6 | proxyquire = require('proxyquire'); 7 | 8 | var reqSpy = sinon.spy(); 9 | var cradle = proxyquire('../lib/cradle', { 10 | request: reqSpy 11 | }); 12 | 13 | vows.describe('cradle/raw-request').addBatch({ 14 | 'Options specified in "request" are passed directly to request library': { 15 | topic: new(cradle.Connection)({ request: { someOption: 'filler' }}), 16 | 'should pass through values to "request"': function(topic) { 17 | var args; 18 | var opts = { 19 | moreOptions: 'moreFiller', 20 | path: 'path' 21 | }; 22 | topic.rawRequest(opts); 23 | args = reqSpy.getCall(0).args[0]; 24 | assert(args.moreOptions, 'moreFiller'); 25 | assert(args.someOption, 'filler'); 26 | } 27 | } 28 | }).export(module); 29 | -------------------------------------------------------------------------------- /test/errors-test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | vows = require('vows'); 3 | 4 | var cradle = require('../lib/cradle'); 5 | 6 | vows.describe('cradle/errors').addBatch({ 7 | 'A cradle.CouchError instance': { 8 | topic: new(cradle.CouchError)({ 9 | 'error': 'not_found', 10 | 'reason': 'no_db_file' 11 | }), 12 | 'should be a instanceOf `Error`': function(err) { 13 | assert.instanceOf(err, Error); 14 | }, 15 | 'shold be throwable': function(err) { 16 | assert.throws(function() { 17 | throw err; 18 | }, 19 | function(err){ 20 | return err.message === 'not_found: no_db_file'; 21 | }); 22 | }, 23 | 'should have a `error` key of type string`': function(err) { 24 | assert.equal('string', typeof err.error); 25 | }, 26 | 'should have a reason` key of type `string`': function(err) { 27 | assert.equal('string', typeof err.error); 28 | } 29 | } 30 | }).export(module); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cradle", 3 | "version": "0.7.2", 4 | "description": "the high-level, caching, CouchDB library", 5 | "url": "http://cloudhead.io/cradle", 6 | "keywords": [ 7 | "couchdb", 8 | "database", 9 | "couch" 10 | ], 11 | "author": "Alexis Sellier ", 12 | "contributors": [ 13 | { 14 | "name": "Charlie Robbins", 15 | "email": "charlie@nodejitsu.com" 16 | }, 17 | { 18 | "name": "Maciej Malecki", 19 | "email": "maciej@nodejitsu.com" 20 | } 21 | ], 22 | "main": "./lib/cradle", 23 | "dependencies": { 24 | "follow": "0.11.x", 25 | "request": "2.x.x", 26 | "vargs": "0.1.0" 27 | }, 28 | "devDependencies": { 29 | "async": "~0.9.0", 30 | "proxyquire": "^1.7.3", 31 | "sinon": "^1.17.2", 32 | "vows": "0.8.x" 33 | }, 34 | "scripts": { 35 | "test": "node test/helpers/seed.js && vows --spec" 36 | }, 37 | "engines": { 38 | "node": ">=0.10.0" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/flatiron/cradle.git" 43 | }, 44 | "license": "MIT" 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2009 cloudhead 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /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 | "updates": { 15 | "newVersion": "function(doc, req) { doc.foo = JSON.parse(req.body).foo; return [doc, JSON.stringify(doc)]; }" 16 | } 17 | }, 18 | { 19 | "_id": "mike", 20 | "color": "pink", 21 | "results": "none" 22 | }, 23 | { 24 | "_id": "bill", 25 | "color": "blue" 26 | }, 27 | { 28 | "_id": "alex", 29 | "color": "red" 30 | }, 31 | { 32 | "_id": "deleteme" 33 | }, 34 | { 35 | "_id": "updateme" 36 | } 37 | ], 38 | "animals/snorlax": [ 39 | { 40 | "_id": "_design/pigs", 41 | "views": { 42 | "all": { "map": "function (doc) { if (doc.color) emit(doc._id, doc) }" } 43 | } 44 | }, 45 | { 46 | "_id": "mike", 47 | "color": "pink", 48 | "rows": 0 49 | }, 50 | { 51 | "_id": "bill", 52 | "color": "blue" 53 | }, 54 | { 55 | "_id": "deleteme" 56 | } 57 | ], 58 | "badgers": null, 59 | "madeup/ewoks": null 60 | } 61 | -------------------------------------------------------------------------------- /lib/cradle/database/changes.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | var events = require('events'), 4 | querystring = require('querystring'), 5 | Args = require('vargs').Constructor, 6 | follow = require('follow'), 7 | cradle = require('../../cradle'), 8 | Database = require('./index').Database; 9 | 10 | Database.prototype.changes = function (options, callback) { 11 | if (typeof(options) === 'function') { 12 | callback = options; 13 | options = {}; 14 | } 15 | 16 | options = options || {}; 17 | 18 | if (callback) { 19 | return this.query({ 20 | method: 'GET', 21 | path: '_changes', 22 | query: options 23 | }, callback); 24 | } 25 | 26 | var response = new events.EventEmitter(), 27 | responded = false, 28 | protocol, 29 | auth = '', 30 | feed; 31 | 32 | if (!options.db) { 33 | protocol = this.connection.protocol || 'http'; 34 | 35 | if (this.connection.auth && this.connection.auth.username && this.connection.auth.password) { 36 | auth = this.connection.auth.username + ':' + this.connection.auth.password + '@'; 37 | } 38 | 39 | options.db = protocol + '://' + auth + this.connection.host + ':' + this.connection.port + '/' + this.name; 40 | } 41 | 42 | feed = new follow.Feed(options); 43 | feed.on('change', function () { 44 | // 45 | // Remark: Support the legacy `data` events. 46 | // 47 | if (!responded) { 48 | responded = true; 49 | feed.emit('response', response); 50 | } 51 | 52 | response.emit.apply(response, ['data'].concat(Array.prototype.slice.call(arguments))); 53 | }); 54 | 55 | if (options.follow !== false) { 56 | feed.follow(); 57 | } 58 | 59 | return feed; 60 | }; 61 | -------------------------------------------------------------------------------- /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 | /*jshint node:true */ 2 | 3 | var querystring = require('querystring'), 4 | Args = require('vargs').Constructor, 5 | cradle = require('../../cradle'); 6 | 7 | var Database = exports.Database = function (name, connection) { 8 | this.connection = connection; 9 | this.name = encodeURIComponent(name); 10 | this.cache = new (cradle.Cache)(connection.options); 11 | }; 12 | 13 | // A wrapper around `Connection.request`, 14 | // which prepends the database name. 15 | Database.prototype.query = function (options, callback) { 16 | options.path = [this.name, options.path].filter(Boolean).join('/'); 17 | return this.connection.request(options, callback); 18 | }; 19 | 20 | Database.prototype.exists = function (callback) { 21 | this.query({ method: 'HEAD' }, function (err, res, status) { 22 | if (err) { 23 | callback(err); 24 | } else { 25 | if (status < 200 || status > 300) { 26 | callback(null, false); 27 | } else { 28 | callback(null, true); 29 | } 30 | } 31 | }); 32 | }; 33 | 34 | Database.prototype.replicate = function (target, options, callback) { 35 | if (typeof(options) === 'function') { 36 | callback = options; 37 | options = {}; 38 | } 39 | this.connection.replicate(cradle.merge({ source: this.name, target: target }, options), callback); 40 | }; 41 | 42 | Database.prototype.info = function (callback) { 43 | this.query({ method: 'GET' }, callback); 44 | }; 45 | 46 | Database.prototype.create = function (callback) { 47 | this.query({ method: 'PUT' }, callback); 48 | }; 49 | 50 | Database.prototype.maxRevisions = function (revisions, callback) { 51 | if (typeof(revisions) === 'function') { 52 | callback = revisions; 53 | var options = { 54 | method: 'GET', 55 | path: '_revs_limit' 56 | }; 57 | options.path = [this.name, options.path].filter(Boolean).join('/'); 58 | this.connection.rawRequest(options, function(err, res) { 59 | if (err) { 60 | return callback && callback(true, null); 61 | } else { 62 | return callback && callback(null, parseInt(res.body, 10)); 63 | } 64 | }); 65 | } else { 66 | this.query({ 67 | method: 'PUT', 68 | path: '_revs_limit', 69 | body: !Number.isNaN(revisions) ? parseInt(revisions, 10) : 1000 70 | }, callback); 71 | } 72 | }; 73 | 74 | // Destroys a database with 'DELETE' 75 | // we raise an exception if arguments were supplied, 76 | // as we don't want users to confuse this function with `remove`. 77 | Database.prototype.destroy = function (callback) { 78 | if (arguments.length > 1) { 79 | throw new(Error)("destroy() doesn't take any additional arguments"); 80 | } 81 | 82 | this.query({ 83 | method: 'DELETE', 84 | path: '/' 85 | }, callback); 86 | }; 87 | 88 | // 89 | // Extend the Database prototype with Couch features 90 | // 91 | require('./attachments'); 92 | require('./changes'); 93 | require('./documents'); 94 | require('./views'); 95 | require('./mango'); 96 | -------------------------------------------------------------------------------- /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 | /*jshint node:true */ 2 | 3 | var Response = require('./response').Response; 4 | // 5 | // Each database object has its own cache store. 6 | // The cache.* methods are all wrappers around 7 | // `cache.query`, which transparently checks if 8 | // caching is enabled, before performing any action. 9 | // 10 | this.Cache = function (options) { 11 | var that = this; 12 | 13 | this.store = {}; 14 | this.options = options; 15 | this.size = options.cacheSize || 0; 16 | this.keys = 0; 17 | }; 18 | 19 | this.Cache.prototype = { 20 | // API 21 | get: function (id) { return this.query('get', id); }, 22 | save: function (id, doc) { return this.query('save', id, doc); }, 23 | purge: function (id) { return this.query('purge', id); }, 24 | has: function (id) { return this.query('has', id); }, 25 | 26 | _get: function (id) { 27 | var entry; 28 | 29 | if (id in this.store) { 30 | entry = this.store[id]; 31 | entry.atime = Date.now(); 32 | 33 | if (this.options.raw) { 34 | return entry.document; 35 | } else { 36 | // If the document is already wrapped in a `Response`, 37 | // just return it. Else, wrap it first. We clone the documents 38 | // before returning them, to protect them from modification. 39 | if (entry.document.toJSON) { 40 | return clone(entry.document); 41 | } else { 42 | return new(Response)(clone(entry.document)); 43 | } 44 | } 45 | } 46 | }, 47 | _has: function (id) { 48 | return id in this.store; 49 | }, 50 | _save: function (id, doc) { 51 | if (! this._has(id)) { 52 | this.keys ++; 53 | this.prune(); 54 | } 55 | 56 | this.store[id] = { 57 | atime: Date.now(), 58 | document: doc 59 | }; 60 | 61 | return this.store[id]; 62 | }, 63 | _purge: function (id) { 64 | if (id) { 65 | delete(this.store[id]); 66 | this.keys --; 67 | } else { 68 | this.store = {}; 69 | } 70 | }, 71 | query: function (op, id, doc) { 72 | if (this.options.cache) { 73 | return this['_' + op](id, doc); 74 | } else { 75 | return false; 76 | } 77 | }, 78 | prune: function () { 79 | var that = this; 80 | if (this.size && this.keys > this.size) { 81 | process.nextTick(function () { 82 | var store = that.store, 83 | keys = Object.keys(store), 84 | pruned = Math.ceil(that.size / 8); 85 | 86 | keys.sort(function (a, b) { 87 | return store[a].atime > store[b].atime ? 1 : -1; 88 | }); 89 | 90 | for (var i = 0; i < pruned; i++) { 91 | delete(store[keys[i]]); 92 | } 93 | that.keys -= pruned; 94 | }); 95 | } 96 | } 97 | }; 98 | 99 | function clone(obj) { 100 | return Object.keys(obj).reduce(function (clone, k) { 101 | if (! obj.__lookupGetter__(k)) { 102 | clone[k] = obj[k]; 103 | } 104 | return clone; 105 | }, {}); 106 | } 107 | -------------------------------------------------------------------------------- /lib/cradle/database/attachments.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | var querystring = require('querystring'), 4 | Args = require('vargs').Constructor, 5 | cradle = require('../../cradle'), 6 | Database = require('./index').Database; 7 | 8 | Database.prototype.getAttachment = function (id, attachmentName, callback) { 9 | // 10 | // TODO: Update cache? 11 | // 12 | return this.connection.rawRequest({ 13 | method: 'GET', 14 | path: '/' + [this.name, querystring.escape(id), attachmentName].join('/'), 15 | encoding: null 16 | }, callback); 17 | }; 18 | 19 | Database.prototype.removeAttachment = function (doc, attachmentName, callback) { 20 | var params, 21 | rev, 22 | id, 23 | error; 24 | 25 | if (typeof doc === 'string') { 26 | id = doc; 27 | } else { 28 | id = doc.id || doc._id; 29 | rev = doc.rev || doc._rev; 30 | } 31 | 32 | if (!id) { 33 | error = new(TypeError)("first argument must be a document id"); 34 | if (!callback) { 35 | throw error; 36 | } 37 | return callback(error); 38 | } 39 | 40 | if (!rev && this.cache.has(id)) { 41 | rev = this.cache.get(id)._rev; 42 | } else if (rev) { 43 | rev = rev.replace(/\"/g, ''); 44 | } 45 | 46 | this.query({ 47 | method: 'DELETE', 48 | path: [querystring.escape(id), attachmentName].join('/'), 49 | query: { rev: rev } 50 | }, callback); 51 | }; 52 | 53 | Database.prototype.saveAttachment = function (doc, attachment, callback) { 54 | var attachmentName, 55 | options = {}, 56 | self = this, 57 | params, 58 | error, 59 | rev, 60 | id; 61 | 62 | if (typeof doc === 'string') { 63 | id = doc; 64 | } else { 65 | id = doc.id || doc._id; 66 | rev = doc.rev || doc._rev; 67 | } 68 | 69 | if (!id) { 70 | error = new(TypeError)("Missing document id."); 71 | if (!callback) { 72 | throw error; 73 | } 74 | return callback(error); 75 | } 76 | 77 | attachmentName = typeof attachment !== 'string' ? attachment.name : attachment; 78 | 79 | if (!rev && this.cache.has(id)) { 80 | params = { rev: this.cache.get(id)._rev }; 81 | } else if (rev) { 82 | params = { rev: rev.replace(/\"/g, '') }; 83 | } 84 | 85 | options.method = 'PUT'; 86 | options.path = '/' + [this.name, querystring.escape(id), attachmentName].join('/'); 87 | options.headers = { 88 | 'Content-Type': attachment['content-type'] || 89 | attachment.contentType || 90 | attachment['Content-Type'] || 91 | 'text/plain' 92 | }; 93 | 94 | if (attachment.contentLength) { 95 | options.headers['Content-Length'] = attachment.contentLength; 96 | } 97 | 98 | if (attachment.body) { 99 | options.body = attachment.body; 100 | } 101 | 102 | if (params) { 103 | options.path += ('?' + querystring.stringify(params)); 104 | } 105 | 106 | return this.connection.rawRequest(options, function (err, res, body) { 107 | if (err) { 108 | return callback(err); 109 | } 110 | 111 | var result = JSON.parse(body); 112 | result.headers = res.headers; 113 | result.headers.status = res.statusCode; 114 | 115 | if (result.headers.status == 201) { 116 | if (self.cache.has(id)) { 117 | // FIXME: Is this supposed to be this.cached? 118 | cached = self.cache.store[id].document; 119 | cached._rev = result.rev; 120 | cached._attachments = cached._attachments || {}; 121 | cached._attachments[attachmentName] = { stub: true }; 122 | } 123 | 124 | return callback(null, result); 125 | } 126 | 127 | callback(result); 128 | }); 129 | }; 130 | 131 | // 132 | // Alias `saveAttachment` to `addAttachment` 133 | // 134 | Database.prototype.addAttachment = Database.prototype.saveAttachment; 135 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/cradle/response.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | // 4 | // HTTP response wrapper 5 | // 6 | // It allows us to call array-like methods on documents 7 | // with a 'row' attribute. 8 | // 9 | this.Response = function Response(json, response) { 10 | var obj, headers; 11 | 12 | // If there's an _id key, it's the result 13 | // of a document retrieval. 14 | // Avoid potential key collisions. 15 | if (!json._id) { 16 | // If there's rows, this is the result 17 | // of a view function. 18 | // We want to return this as an Array. 19 | if (json.rows) { 20 | obj = json.rows.slice(0); 21 | obj.__proto__ = new(Array); 22 | if (json && typeof json === 'object') { 23 | Object.keys(json).forEach(function (k) { 24 | Object.defineProperty(obj.__proto__, k, { 25 | value: json[k], 26 | enumerable: false 27 | }); 28 | }); 29 | } 30 | } else if (json.results) { 31 | obj = json.results.slice(0); 32 | obj.__proto__ = new(Array); 33 | obj.last_seq = json.last_seq; 34 | } else if (json.uuids) { 35 | obj = json.uuids; 36 | obj.__proto__ = new(Array); 37 | } else if (Array.isArray(json)) { 38 | obj = json.slice(0); 39 | obj.__proto__ = new(Array); 40 | } 41 | } 42 | 43 | if (!obj) { 44 | obj = {}; 45 | obj.__proto__ = new(Object); 46 | if (json && typeof json === 'object') { 47 | Object.keys(json).forEach(function (k) { 48 | obj[k] = json[k]; 49 | }); 50 | } 51 | } 52 | 53 | // If the response was originally a document, 54 | // give access to it via the 'json' getter. 55 | if (!Array.isArray(json) && !obj.json) { 56 | Object.defineProperty(obj, 'json', { 57 | value: json, 58 | enumerable: false 59 | }); 60 | } 61 | 62 | if (response) { 63 | headers = { status: response.statusCode }; 64 | Object.keys(response.headers).forEach(function (k) { 65 | headers[k] = response.headers[k]; 66 | }); 67 | 68 | // Set the 'headers' special field, with the response's status code. 69 | exports.extend(obj, 'headers' in obj ? { _headers: headers } 70 | : { headers: headers }); 71 | } 72 | 73 | // Alias '_rev' and '_id' 74 | if (obj.id && obj.rev) { 75 | exports.extend(obj, { _id: obj.id, _rev: obj.rev }); 76 | } else if (obj._id && obj._rev) { 77 | exports.extend(obj, { id: obj._id, rev: obj._rev }); 78 | } 79 | 80 | if (Array.isArray(obj) && json.rows) { 81 | exports.extend(obj, exports.collectionPrototype); 82 | } 83 | exports.extend(obj, exports.basePrototype); 84 | 85 | // Set the constructor to be this function 86 | Object.defineProperty(obj, 'constructor', { 87 | value: arguments.callee 88 | }); 89 | 90 | return obj; 91 | }; 92 | 93 | this.basePrototype = { 94 | toJSON: function () { 95 | return this; 96 | }, 97 | toString: function () { 98 | return JSON.stringify(this); 99 | } 100 | }; 101 | 102 | this.collectionPrototype = { 103 | forEach: function (f) { 104 | for (var i = 0, value; i < this.length; i++) { 105 | value = this[i].doc || this[i].json || this[i].value || this[i]; 106 | if (f.length === 1) { 107 | f.call(this[i], value); 108 | } else { 109 | f.call(this[i], this[i].key, value, this[i].id); 110 | } 111 | } 112 | }, 113 | map: function (f) { 114 | var ary = []; 115 | if (f.length === 1) { 116 | this.forEach(function (a) { ary.push(f.call(this, a)); }); 117 | } else { 118 | this.forEach(function () { ary.push(f.apply(this, arguments)); }); 119 | } 120 | return ary; 121 | }, 122 | toArray: function () { 123 | return this.map(function (k, v) { return v; }); 124 | } 125 | }; 126 | 127 | this.extend = function (obj, properties) { 128 | var descriptor = Object.keys(properties).reduce(function (hash, k) { 129 | hash[k] = { 130 | value: properties[k], 131 | enumerable: false 132 | }; 133 | return hash; 134 | }, {}); 135 | return Object.defineProperties(obj, descriptor); 136 | }; 137 | -------------------------------------------------------------------------------- /lib/cradle/database/views.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | var querystring = require('querystring'), 4 | Args = require('vargs').Constructor, 5 | cradle = require('../../cradle'), 6 | Database = require('./index').Database; 7 | 8 | Database.prototype.all = function (options, callback) { 9 | if (arguments.length === 1) { 10 | callback = options; 11 | options = {}; 12 | } 13 | 14 | return this._getOrPostView('/_all_docs', options, callback); 15 | }; 16 | 17 | // Query a view, passing any options to the query string. 18 | // Some query string parameters' values have to be JSON-encoded. 19 | Database.prototype.view = function (path, options, callback) { 20 | if (typeof options === 'function') { 21 | callback = options; 22 | options = {}; 23 | } 24 | 25 | path = path.split('/'); 26 | path = ['_design', path[0], '_view', path[1]].map(querystring.escape).join('/'); 27 | 28 | return this._getOrPostView(path, options, callback); 29 | }; 30 | 31 | Database.prototype.fti = function (path, options, callback) { 32 | if (typeof options === 'function') { 33 | callback = options; 34 | options = {}; 35 | } 36 | 37 | path = path.split('/'); 38 | path = ['_fti', 'local', this.name, '_design', path[0], path[1]].map(querystring.escape).join('/'); 39 | 40 | options = parseOptions(options); 41 | 42 | this.connection.request({method: 'GET', path: path, query: options}, callback); 43 | }; 44 | 45 | Database.prototype.temporaryView = function (doc, options, callback) { 46 | if (!callback && typeof options === 'function') { 47 | callback = options; 48 | options = null; 49 | } 50 | 51 | if (options && typeof options === 'object') { 52 | ['key', 'keys', 'startkey', 'endkey'].forEach(function (k) { 53 | if (k in options) { 54 | options[k] = JSON.stringify(options[k]); 55 | } 56 | }); 57 | } 58 | 59 | return this.query({ 60 | method: 'POST', 61 | path: '_temp_view', 62 | query: options, 63 | body: doc 64 | }, callback); 65 | }; 66 | 67 | Database.prototype.viewCleanup = function (callback) { 68 | this.query({ 69 | method: 'POST', 70 | path: '/_view_cleanup', 71 | headers: { 72 | 'Content-Type': 'application/json' 73 | } 74 | }, callback); 75 | }; 76 | 77 | Database.prototype.compact = function (design) { 78 | this.query({ 79 | method: 'POST', 80 | path: '/_compact' + (typeof(design) === 'string' ? '/' + querystring.escape(design) : ''), 81 | headers: { 82 | 'Content-Type': 'application/json' 83 | } 84 | }, Args.last(arguments)); 85 | }; 86 | 87 | // Query a list, passing any options to the query string. 88 | // Some query string parameters' values have to be JSON-encoded. 89 | Database.prototype.list = function (path, options) { 90 | var callback = new(Args)(arguments).callback; 91 | path = path.split('/'); 92 | 93 | this._getOrPostView( 94 | ['_design', path[0], '_list', path[1], path[2]].map(querystring.escape).join('/'), 95 | options, 96 | callback 97 | ); 98 | }; 99 | 100 | // 101 | // Helper function which parses options and makes either a `GET` 102 | // or `POST` request to `path` depending on if `options.keys` or 103 | // `options.body` is present. 104 | // 105 | Database.prototype._getOrPostView = function (path, options, callback) { 106 | options = parseOptions(options); 107 | 108 | if (options && options.body) { 109 | var body = options.body; 110 | delete options.body; 111 | 112 | return this.query({ 113 | method: 'POST', 114 | path: path, 115 | query: options, 116 | body: body 117 | }, callback); 118 | } 119 | 120 | return this.query({ 121 | method: 'GET', 122 | path: path, 123 | query: options 124 | }, callback); 125 | }; 126 | 127 | // 128 | // Helper function for parsing and stringifying complex options 129 | // to pass to CouchDB. 130 | // 131 | function parseOptions(options) { 132 | if (options && typeof options === 'object') { 133 | options = cradle.merge({}, options); 134 | 135 | ['key', 'startkey', 'endkey'].forEach(function (k) { 136 | if (k in options) { 137 | options[k] = JSON.stringify(options[k]); 138 | } 139 | }); 140 | } 141 | 142 | if (options && options.keys) { 143 | options.body = options.body || {}; 144 | options.body.keys = options.keys; 145 | delete options.keys; 146 | } 147 | 148 | return options; 149 | } 150 | -------------------------------------------------------------------------------- /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.protocol, 'http'); 40 | assert.equal(c.port, 9696); 41 | } 42 | }, 43 | "with a host, port and options passed to Connection": { 44 | topic: function () { return new(cradle.Connection)("4.4.4.4", 911, {raw: true}) }, 45 | "should override the defaults": function (c) { 46 | assert.equal(c.host, '4.4.4.4'); 47 | assert.equal(c.protocol, 'http'); 48 | assert.equal(c.port, 911); 49 | assert.equal(c.options.raw, true); 50 | } 51 | }, 52 | "with a host, port and options (secure: true) passed to Connection": { 53 | topic: function () { return new(cradle.Connection)("4.4.4.4", 911, {raw: true, secure:true}) }, 54 | "should override the defaults and assume https protocol": function (c) { 55 | assert.equal(c.host, '4.4.4.4'); 56 | assert.equal(c.protocol, 'https'); 57 | assert.equal(c.port, 911); 58 | assert.equal(c.options.raw, true); 59 | } 60 | }, 61 | "with a host and port and protocol passed to Connection": { 62 | topic: function () { return new(cradle.Connection)("http://4.4.4.4", 911, {raw: true, secure: true}) }, 63 | "should override the defaults": function (c) { 64 | assert.equal(c.host, '4.4.4.4'); 65 | assert.equal(c.protocol, 'http'); 66 | assert.equal(c.port, 911); 67 | assert.equal(c.options.raw, true); 68 | assert.equal(c.options.secure, true); 69 | } 70 | }, 71 | "with a host and port passed as an object to Connection": { 72 | topic: function () { return new(cradle.Connection)({ host: "https://4.4.4.4", port: 911, raw: true }) }, 73 | "should override the defaults": function (c) { 74 | assert.equal(c.options.secure, true); 75 | assert.equal(c.host, '4.4.4.4'); 76 | assert.equal(c.protocol, 'https'); 77 | assert.equal(c.port, 911); 78 | assert.equal(c.options.raw, true); 79 | } 80 | }, 81 | "with a the 'https' protocol": { 82 | topic: function () { return new(cradle.Connection)("https://couch.io", 5984) }, 83 | "should set 'secure' to `true`": function (c) { 84 | assert.equal(c.protocol, 'https'); 85 | assert.equal(c.options.secure, true); 86 | assert.equal(c.host, 'couch.io'); 87 | assert.equal(c.port, 5984); 88 | } 89 | }, 90 | "with the port as part of the URL": { 91 | topic: function () { return new(cradle.Connection)("https://couch.io:418") }, 92 | "should read the port from the URL": function (c) { 93 | assert.equal(c.protocol, 'https'); 94 | assert.equal(c.options.secure, true); 95 | assert.equal(c.host, 'couch.io'); 96 | assert.equal(c.port, 418); 97 | } 98 | } 99 | } 100 | }).addBatch({ 101 | "Connection": { 102 | topic: function () { 103 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 104 | }, 105 | "getting server info": { 106 | topic: function (c) { c.info(this.callback) }, 107 | 108 | "returns a 200": macros.status(200), 109 | "returns the version number": function (info) { 110 | assert.ok(info); 111 | assert.match(info.version, /\d+\.\d+\.\d+/); 112 | } 113 | }, 114 | "uuids()": { 115 | "with count": { 116 | topic: function (c) { c.uuids(42, this.callback) }, 117 | 118 | "returns a 200": macros.status(200), 119 | "returns an array of UUIDs": function (uuids) { 120 | assert.isArray(uuids); 121 | assert.lengthOf(uuids, 42); 122 | } 123 | }, 124 | "without count": { 125 | topic: function (c) { c.uuids(this.callback) }, 126 | 127 | "returns a 200": macros.status(200), 128 | "returns an array of UUIDs": function (uuids) { 129 | assert.isArray(uuids); 130 | assert.lengthOf(uuids, 1); 131 | } 132 | } 133 | }, 134 | "getting the list of databases": { 135 | topic: function (c) { 136 | c.databases(this.callback); 137 | }, 138 | "should contain the 'rabbits' and 'pigs' databases": function (dbs) { 139 | assert.isArray(dbs); 140 | assert.include(dbs, 'rabbits'); 141 | assert.include(dbs, 'pigs'); 142 | } 143 | }, 144 | } 145 | }).addBatch({ 146 | "Connection": { 147 | topic: function () { 148 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 149 | }, 150 | "create()": { 151 | "with no / in the name": { 152 | topic: function (c) { 153 | c.database('badgers').create(this.callback); 154 | }, 155 | "returns a 201": macros.status(201), 156 | "creates a database": { 157 | topic: function (res, c) { c.database('badgers').exists(this.callback) }, 158 | "it exists": function (res) { assert.ok(res) } 159 | } 160 | }, 161 | "with a / in the name": { 162 | topic: function (c) { 163 | c.database('madeup/ewoks').create(this.callback); 164 | }, 165 | "returns a 201": macros.status(201), 166 | "creates a database": { 167 | topic: function (res, c) { c.database('madeup/ewoks').exists(this.callback) }, 168 | "it exists": function (res) { assert.ok(res) } 169 | } 170 | } 171 | }, 172 | "exists()": { 173 | "with an invalid name": { 174 | topic: function (c) { 175 | c.database('BAD').exists(this.callback); 176 | }, 177 | "should respond with false": { 178 | "it exists": function (exists) { assert.isFalse(exists); } 179 | } 180 | } 181 | }, 182 | "destroy()": { 183 | topic: function (c) { 184 | c.database('rabbits').destroy(this.callback); 185 | }, 186 | "returns a 200": macros.status(200), 187 | "destroys a database": { 188 | topic: function (res, c) { 189 | c.database('rabbits').exists(this.callback); 190 | }, 191 | "it doesn't exist anymore": function (res) { assert.ok(! res) } 192 | } 193 | } 194 | } 195 | }).export(module); 196 | -------------------------------------------------------------------------------- /lib/cradle/database/documents.js: -------------------------------------------------------------------------------- 1 | /*jshint node:true */ 2 | 3 | var querystring = require('querystring'), 4 | Args = require('vargs').Constructor, 5 | cradle = require('../../cradle'), 6 | Database = require('./index').Database; 7 | 8 | // 9 | // Perform a HEAD request 10 | // 11 | Database.prototype.head = function (id, callback) { 12 | this.query({ 13 | method: 'HEAD', 14 | path: cradle.escape(id) 15 | }, callback); 16 | }; 17 | 18 | // Fetch either a single document from the database, or cache, 19 | // or multiple documents from the database. 20 | // If it's a single doc from the db, attempt to save it to the cache. 21 | Database.prototype.get = function (id, rev) { 22 | var args = new (Args)(arguments), 23 | options = null, 24 | that = this; 25 | 26 | if (Array.isArray(id)) { // Bulk GET 27 | this.query({ 28 | method: 'POST', 29 | path: '/_all_docs', 30 | query: { include_docs: true }, 31 | body: { keys: id }, 32 | }, function (err, res) { 33 | args.callback(err, res); 34 | }); 35 | } else { 36 | if (rev && args.length === 2) { 37 | if (typeof(rev) === 'string') { 38 | options = { 39 | rev: rev 40 | }; 41 | } else if (typeof(rev) === 'object') { 42 | options = rev; 43 | } 44 | } else if (this.cache.has(id)) { 45 | return args.callback(null, this.cache.get(id)); 46 | } 47 | this.query({ 48 | path: cradle.escape(id), 49 | query: options 50 | }, function (err, res) { 51 | if (! err) that.cache.save(res.id, res.json); 52 | args.callback(err, res); 53 | }); 54 | } 55 | }; 56 | 57 | // 58 | // PUT a document, and write through cache 59 | // 60 | Database.prototype.put = function (id, doc, callback) { 61 | var cache = this.cache; 62 | if (typeof(id) !== 'string') { 63 | throw new(TypeError)("id must be a string"); 64 | } 65 | this.query({ 66 | method: 'PUT', 67 | path: cradle.escape(id), 68 | body: doc 69 | }, function (e, res) { 70 | if (! e) { 71 | cache.save(id, cradle.merge({}, doc, { _id: id, _rev: res.rev })); 72 | } 73 | callback && callback(e, res); 74 | }); 75 | }; 76 | 77 | // 78 | // POST a document, and write through cache 79 | // 80 | Database.prototype.post = function (doc, callback) { 81 | var cache = this.cache; 82 | this.query({ 83 | method: 'POST', 84 | path: '/', 85 | body: doc 86 | }, function (e, res) { 87 | if (! e) { 88 | cache.save(res.id, cradle.merge({}, doc, { _id: res.id, _rev: res.rev })); 89 | } 90 | callback && callback(e, res); 91 | }); 92 | }; 93 | 94 | Database.prototype.save = function (/* [id], [rev], doc | [doc, ...] */) { 95 | var args = new(Args)(arguments), 96 | array = args.all.slice(0), doc, id, rev; 97 | 98 | if (Array.isArray(args.first)) { 99 | doc = args.first; 100 | } else { 101 | doc = array.pop(); 102 | id = array.shift(); 103 | rev = array.shift(); 104 | } 105 | this._save(id, rev, doc, args.callback); 106 | }; 107 | 108 | Database.prototype._save = function (id, rev, doc, callback) { 109 | var options = this.connection.options; 110 | var document = {}, that = this; 111 | 112 | // Bulk Insert 113 | if (Array.isArray(doc)) { 114 | document.docs = doc; 115 | if (options.allOrNothing) { 116 | document.all_or_nothing = true; 117 | } 118 | this.query({ 119 | method: 'POST', 120 | path: '/_bulk_docs', 121 | body: document 122 | }, callback); 123 | } else { 124 | if (!id && doc._id) { 125 | id = doc._id; 126 | } 127 | 128 | // PUT a single document, with an id (Create or Update) 129 | if (id) { 130 | // Design document 131 | if (/^_design\/(\w|%|\-)+$/.test(id) && !('views' in doc)) { 132 | document.language = "javascript"; 133 | document.views = doc; 134 | } else { 135 | document = doc; 136 | } 137 | // Try to set the '_rev' attribute of the document. 138 | // If it wasn't passed, attempt to retrieve it from the cache. 139 | rev && (document._rev = rev); 140 | 141 | if (document._rev) { 142 | this.put(id, document, callback); 143 | } else if (this.cache.has(id)) { 144 | document._rev = this.cache.get(id)._rev; 145 | this.put(id, document, callback); 146 | } else { 147 | // Attempt to create a new document. If it fails, 148 | // because an existing document with that _id exists (409), 149 | // perform a HEAD, to get the _rev, and try to re-save. 150 | this.put(id, document, function (e, res) { 151 | if (e && e.headers && e.headers.status === 409 && options.forceSave) { // Conflict 152 | that.head(id, function (e, headers, res) { 153 | if (res === 404 || !headers.etag) { 154 | return callback({ reason: 'not_found' }); 155 | } 156 | 157 | document._rev = headers.etag.slice(1, -1); 158 | that.put(id, document, callback); 159 | }); 160 | } else { 161 | callback(e, res); 162 | } 163 | }); 164 | } 165 | // POST a single document, without an id (Create) 166 | } else { 167 | this.post(doc, callback); 168 | } 169 | } 170 | }; 171 | 172 | Database.prototype.merge = function (/* [id], doc */) { 173 | var args = Array.prototype.slice.call(arguments), 174 | callback = args.pop(), 175 | doc = args.pop(), 176 | id = args.pop() || doc._id; 177 | 178 | this._merge(id, doc, callback); 179 | }; 180 | 181 | Database.prototype._merge = function (id, doc, callback) { 182 | var that = this; 183 | this.get(id, function (e, res) { 184 | if (e) { 185 | return callback(e); 186 | } 187 | doc = cradle.merge({}, res.json || res, doc); 188 | that.save(id, res._rev, doc, callback); 189 | }); 190 | }; 191 | 192 | Database.prototype.insert = function () { 193 | throw new Error("`insert` is deprecated, use `save` instead"); 194 | }; 195 | 196 | // Update document handler 197 | // body is an optional parameter for passing data in the body which is not limited by the 198 | // 8197 characters limit of the query parameter 199 | Database.prototype.update = function (path, id, options, body) { 200 | var args = new(Args)(arguments); 201 | path = path.split('/'); 202 | 203 | if (id) { 204 | return this.query({ 205 | method: 'PUT', 206 | path: ['_design', path[0], '_update', path[1], id].map(querystring.escape).join('/'), 207 | query: options, 208 | body: body 209 | }, args.callback); 210 | } 211 | 212 | return this.query({ 213 | method: 'POST', 214 | path: ['_design', path[0], '_update', path[1]].map(querystring.escape).join('/'), 215 | query: options, 216 | body: body 217 | }, args.callback); 218 | }; 219 | 220 | // Delete a document 221 | // If the _rev wasn't supplied, we attempt to retrieve it from the 222 | // cache. Otherwise, we attempt to get the _rev first. If the deletion 223 | // was successful, we purge the cache. 224 | Database.prototype.remove = function (id, rev) { 225 | var that = this, doc, args = new(Args)(arguments); 226 | 227 | // 228 | // Removes the document with `id` at `rev`. 229 | // 230 | function remove() { 231 | that.query({ 232 | method: 'DELETE', 233 | path: cradle.escape(id), 234 | query: { rev: rev } 235 | }, function (err, res) { 236 | if (! err) { 237 | that.cache.purge(id); 238 | } 239 | args.callback(err, res); 240 | }); 241 | } 242 | 243 | if (typeof(rev) !== 'string') { 244 | if (doc = this.cache.get(id)) { 245 | rev = doc._rev; 246 | } 247 | else { 248 | return this.get(id, function (err, _doc) { 249 | if (err) { 250 | return args.callback(err); 251 | } 252 | else if (!_doc._rev) { 253 | return args.callback(new Error('No _rev found for ' + id)); 254 | } 255 | 256 | rev = _doc._rev; 257 | remove(); 258 | }); 259 | } 260 | } 261 | 262 | remove(); 263 | }; 264 | -------------------------------------------------------------------------------- /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 | "getting database max revisions (GET)": { 201 | topic: function(db) { 202 | var promise = new(events.EventEmitter); 203 | db.maxRevisions(function(e, res) { 204 | promise.emit('success', res); 205 | }); 206 | return promise; 207 | }, 208 | "returns default value (1000)": function(res) { 209 | assert.equal(res, 1000); 210 | } 211 | }, 212 | "setting database max revisions to 10 (PUT)": { 213 | topic: function(db) { 214 | var promise = new(events.EventEmitter); 215 | db.maxRevisions(10, function(err, putResponse) { 216 | db.maxRevisions(function(err1, getResponse) { 217 | promise.emit('success', putResponse, getResponse); 218 | /* Reset to default */ 219 | db.maxRevisions(1000, function(e, res) {}); 220 | }); 221 | }); 222 | return promise; 223 | }, 224 | "returns true": function(err, putResponse) { 225 | assert.isTrue(putResponse.ok); 226 | }, 227 | "new value is assigned (10)": function(err, putResponse, getResponse) { 228 | assert.equal(getResponse, 10); 229 | } 230 | } 231 | } 232 | } 233 | 234 | var cradle = require('../lib/cradle'); 235 | 236 | vows.describe('cradle/database').addBatch({ 237 | "Connection": { 238 | topic: function () { 239 | return new(cradle.Connection)('127.0.0.1', 5984, {cache: false}); 240 | }, 241 | "database() with no /": shouldQueryCouch('pigs'), 242 | "database() with /": shouldQueryCouch('animals/snorlax'), 243 | "update()": { 244 | topic: function (db) { db.database('pigs').update('pigs/newVersion', 'updateme', undefined, { foo: 123 }, this.callback) }, 245 | "responds with the document": function (res) { 246 | assert(JSON.parse(res).foo === 123); 247 | } 248 | }, 249 | } 250 | }).export(module); 251 | -------------------------------------------------------------------------------- /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 | cradle.CouchError = require('./cradle/errors').CouchError; 17 | 18 | cradle.host = '127.0.0.1'; 19 | cradle.port = 5984; 20 | cradle.auth = null; 21 | cradle.ca = null; 22 | cradle.options = { 23 | cache: true, 24 | raw: false, 25 | secure: false, 26 | retries: 0, 27 | retryTimeout: 10e3, 28 | forceSave: true, 29 | headers: {} 30 | }; 31 | 32 | cradle.setup = function (settings) { 33 | this.host = settings.host; 34 | this.auth = settings.auth; 35 | if (settings.port) { 36 | this.port = parseInt(settings.port, 10); 37 | } 38 | cradle.merge(this.options, settings); 39 | 40 | return this; 41 | }; 42 | 43 | var protocolPattern = /^(https?):\/\//; 44 | 45 | cradle.Connection = function Connection(/* variable args */) { 46 | var args = Array.prototype.slice.call(arguments), 47 | options = {}, 48 | remote, 49 | match, 50 | host, 51 | port, 52 | ca, 53 | agentOptions = {}, 54 | auth; 55 | 56 | args.forEach(function (a) { 57 | if (typeof(a) === 'number' || (typeof(a) === 'string' && /^\d{2,5}$/.test(a))) { 58 | port = parseInt(a); 59 | } else if (typeof(a) === 'object') { 60 | options = a; 61 | host = host || options.hostname || options.host; 62 | port = port || options.port; 63 | auth = options.auth; 64 | ca = options.ca; 65 | } else { 66 | host = a; 67 | 68 | if (match = host.match(/^(.+)\:(\d{2,5})$/)) { 69 | host = match[1]; 70 | port = parseInt(match[2]); 71 | } 72 | } 73 | }); 74 | 75 | if (typeof auth == "string") { 76 | // probaby via a url.parse() 77 | var userpass = auth.split(":"); 78 | auth = {}; 79 | auth.username = userpass[0]; 80 | auth.password = userpass[1] || null; 81 | } 82 | 83 | this.host = host || cradle.host; 84 | this.port = port || cradle.port; 85 | this.auth = auth || cradle.auth; 86 | this.ca = ca || cradle.ca; 87 | this.options = cradle.merge({}, cradle.options, options); 88 | 89 | this.options.maxSockets = this.options.maxSockets || 20; 90 | this.options.secure = this.options.secure || this.options.ssl; 91 | 92 | if (protocolPattern.test(this.host)) { 93 | this.protocol = this.host.match(protocolPattern)[1]; 94 | this.host = this.host.replace(protocolPattern, ''); 95 | } 96 | 97 | if (this.protocol === 'https') this.options.secure = true; 98 | 99 | if (!this.protocol) { 100 | this.protocol = (this.options.secure) ? 'https' : 'http'; 101 | } 102 | 103 | if (this.options.ssl) { // Deprecation warning 104 | console.log('Warning: "ssl" option is deprecated. Use "secure" instead.'); 105 | } 106 | 107 | agentOptions.host = this.host; 108 | agentOptions.port = this.port; 109 | if (this.options.secure) { 110 | this.transport = https; 111 | if (this.ca) { 112 | agentOptions.ca = this.ca; 113 | } 114 | } else { 115 | this.transport = http; 116 | } 117 | this.agent = new (this.transport.Agent)(agentOptions); 118 | 119 | this.agent.maxSockets = this.options.maxSockets; 120 | }; 121 | 122 | // 123 | // Connection.rawRequest() 124 | // 125 | // This is a base wrapper around connections to CouchDB. Given that it handles 126 | // *all* requests, including those for attachments, it knows nothing about 127 | // JSON serialization and does not presuppose it is sending or receiving JSON 128 | // content 129 | // 130 | // OLDAPI: function (method, path, options, data, headers) 131 | // 132 | cradle.Connection.prototype.rawRequest = function (options, callback) { 133 | var promise = new(events.EventEmitter), 134 | self = this; 135 | 136 | // HTTP Headers 137 | options.headers = options.headers || {}; 138 | 139 | // Set HTTP Basic Auth 140 | if (this.auth) { 141 | options.auth = this.auth; 142 | } 143 | 144 | // Set client-wide headers 145 | Object.keys(this.options.headers).forEach(function (header) { 146 | options.headers[header] = self.options.headers[header]; 147 | }); 148 | 149 | if (options.query && Object.keys(options.query).length) { 150 | for (var k in options.query) { 151 | if (typeof(options.query[k]) === 'boolean') { 152 | options.query[k] = String(options.query[k]); 153 | } 154 | } 155 | options.path += '?' + querystring.stringify(options.query); 156 | } 157 | 158 | options.headers['Connection'] = options.headers['Connection'] || 'keep-alive'; 159 | options.agent = this.agent; 160 | options.uri = this._url(options.path); 161 | delete options.path; 162 | options = cradle.merge(this.options.request || {}, options); 163 | 164 | return request(options, callback || function () { }); 165 | }; 166 | 167 | // 168 | // Connection.close() 169 | // 170 | // Close all underlying sockets associated with the agent for the connection. 171 | // 172 | cradle.Connection.prototype.close = function () { 173 | this.agent.sockets.forEach(function (socket) { 174 | socket.end(); 175 | }); 176 | } 177 | 178 | // 179 | // Connection.request() 180 | // 181 | // This is the entry point for all requests to CouchDB, at this point, 182 | // the database name has been embed in the url, by one of the wrappers. 183 | // 184 | cradle.Connection.prototype.request = function (options, callback) { 185 | var headers = cradle.merge({ host: this.host }, options.headers || {}), 186 | self = this; 187 | 188 | callback = callback || function () {}; 189 | 190 | // HTTP Headers 191 | options.headers = options.headers || {}; 192 | 193 | // 194 | // Handle POST/PUT data. We also convert functions to strings, 195 | // so they can be used in _design documents. 196 | // 197 | if (options.body) { 198 | options.body = JSON.stringify(options.body, function (k, val) { 199 | if (typeof(val) === 'function') { 200 | return val.toString(); 201 | } else { return val } 202 | }); 203 | options.headers["Content-Length"] = Buffer.byteLength(options.body); 204 | options.headers["Content-Type"] = "application/json"; 205 | } 206 | 207 | if (options.method === "DELETE" && !options.headers["Content-Length"]) { 208 | options.headers["Content-Length"] = 0; 209 | } 210 | 211 | var attempts = 0; 212 | return this.rawRequest(options, function _onResponse(err, res, body) { 213 | attempts++; 214 | if (err) { 215 | if (self.options.retries && 216 | (!options.method || options.method.toLowerCase() === 'get' || options.body) && 217 | String(err.code).indexOf('ECONN') === 0 && attempts <= self.options.retries 218 | ) { 219 | return setTimeout( 220 | self.rawRequest.bind(self, options, _onResponse), 221 | self.options.retryTimeout 222 | ); 223 | } 224 | return callback(err); 225 | } 226 | else if (options.method === 'HEAD') { 227 | return callback(null, res.headers, res.statusCode); 228 | } 229 | else if (body && body.error) { 230 | cradle.extend(body, { headers: res.headers }); 231 | body.headers.status = res.statusCode; 232 | return callback(new cradle.CouchError(body)); 233 | } 234 | 235 | try { body = JSON.parse(body) } 236 | catch (err) { } 237 | 238 | if (body && body.error) { 239 | cradle.extend(body, { headers: res.headers }); 240 | body.headers.status = res.statusCode; 241 | return callback(new cradle.CouchError(body)); 242 | } 243 | 244 | callback(null, self.options.raw ? body : new cradle.Response(body, res)); 245 | }); 246 | }; 247 | 248 | // 249 | // The database object 250 | // 251 | // We return an object with database functions, 252 | // closing around the `name` argument. 253 | // 254 | cradle.Connection.prototype.database = function (name) { 255 | return new cradle.Database(name, this) 256 | }; 257 | 258 | // 259 | // Wrapper functions for the server API 260 | // 261 | cradle.Connection.prototype.databases = function (callback) { 262 | this.request({ path: '/_all_dbs' }, callback); 263 | }; 264 | cradle.Connection.prototype.config = function (callback) { 265 | this.request({ path: '/_config' }, callback); 266 | }; 267 | cradle.Connection.prototype.info = function (callback) { 268 | this.request({ path: '/' }, callback); 269 | }; 270 | cradle.Connection.prototype.stats = function (callback) { 271 | this.request({ path: '/_stats' }, callback); 272 | }; 273 | cradle.Connection.prototype.activeTasks = function (callback) { 274 | this.request({ path: '/_active_tasks' }, callback); 275 | }; 276 | cradle.Connection.prototype.uuids = function (count, callback) { 277 | if (typeof(count) === 'function') { 278 | callback = count; 279 | count = null; 280 | } 281 | 282 | this.request({ 283 | method: 'GET', 284 | path: '/_uuids', 285 | query: count ? { count: count } : {} 286 | }, callback); 287 | }; 288 | cradle.Connection.prototype.replicate = function (options, callback) { 289 | this.request({ 290 | method: 'POST', 291 | path: '/_replicate', 292 | body: options 293 | }, callback); 294 | }; 295 | 296 | cradle.Connection.prototype._url = function (path) { 297 | var url = (this.protocol || 'http') + '://' + this.host; 298 | if (this.port !== 443 && this.port !== 80) { 299 | url += ':' + this.port; 300 | } 301 | 302 | url += path[0] === '/' ? path : ('/' + path); 303 | return url; 304 | } 305 | 306 | cradle.escape = function (id) { 307 | return ['_design', '_changes', '_temp_view'].indexOf(id.split('/')[0]) === -1 308 | ? querystring.escape(id) 309 | : id; 310 | }; 311 | 312 | cradle.merge = function (target) { 313 | var objs = Array.prototype.slice.call(arguments, 1); 314 | objs.forEach(function (o) { 315 | Object.keys(o).forEach(function (attr) { 316 | if (! o.__lookupGetter__(attr)) { 317 | target[attr] = o[attr]; 318 | } 319 | }); 320 | }); 321 | return target; 322 | }; 323 | -------------------------------------------------------------------------------- /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 | if (e) { 27 | console.error(e); 28 | } 29 | db.saveAttachment({ 30 | id: res.id, 31 | rev: res.rev 32 | }, { 33 | name: 'cached/foo.txt', 34 | 'Content-Type': 'text/plain', 35 | body: 'Foo!' 36 | }, function (e, res) { 37 | if (e) { 38 | console.error(e); 39 | } 40 | that.callback(null, db.cache.get(res.id)); 41 | }); 42 | }); 43 | }, 44 | "with the revision": function (cached) { 45 | assert.match(cached._rev, /^2-/); 46 | }, 47 | "with the _attachments": function (cached) { 48 | assert.ok(cached._attachments); 49 | assert.ok(cached._attachments['cached/foo.txt']); 50 | assert.equal(cached._attachments['cached/foo.txt'].stub, true); 51 | }, 52 | "and is valid enough to re-save": { 53 | topic: function (cached, db) { 54 | var that = this 55 | db.save(mixin({ foo: 'bar' }, cached), function (e,res) { 56 | db.cache.purge(cached._id); 57 | db.get(cached._id, that.callback); 58 | }); 59 | }, 60 | "has the attachment": function (res) { 61 | var att = res._attachments['cached/foo.txt']; 62 | assert.equal(att.stub, true); 63 | assert.equal(att.content_type, 'text/plain'); 64 | assert.equal(att.length, 4); 65 | assert.equal(att.revpos, 2); 66 | }, 67 | "and actually updated the rev": function (res) { 68 | assert.match(res._rev, /^3-/); 69 | } 70 | } 71 | }, 72 | "pulls the revision from the cache if not given": { 73 | topic: function (db) { 74 | var callback = this.callback; 75 | db.save({ _id: 'attachment-saving-pulls-rev-from-cache' }, function (e, res) { 76 | db.saveAttachment(res.id, { 77 | name: 'foo.txt', 78 | contentType: 'text/plain', 79 | body: 'Foo!' 80 | }, callback); 81 | }); 82 | }, 83 | "and saves successfully": macros.status(201) 84 | } 85 | } 86 | }) 87 | ).addBatch( 88 | macros.database({ 89 | "putting an attachment": { 90 | "to an existing document": { 91 | "with given data": { 92 | topic: function (db) { 93 | var that = this; 94 | db.save({_id: 'complete-attachment'}, function (e, res) { 95 | db.saveAttachment({ 96 | id: res.id, 97 | rev: res.rev 98 | }, { 99 | name: 'foo.txt', 100 | 'content-type': 'text/plain', 101 | body: 'Foo!' 102 | }, that.callback); 103 | }); 104 | }, 105 | "returns a 201": macros.status(201), 106 | "returns the revision": function (res) { 107 | assert.ok(res.rev); 108 | assert.match(res.rev, /^2/); 109 | }, 110 | }, 111 | "when piping": { 112 | topic: function (db) { 113 | var callback = this.callback, filestream; 114 | db.save({ _id: 'piped-attachment' }, function (e, res) { 115 | var stream = db.saveAttachment({ 116 | id: res.id, 117 | rev: res.rev 118 | }, { 119 | name: 'foo.txt', 120 | contentType: 'text/plain' 121 | }, callback); 122 | 123 | fs.createReadStream(__dirname + "/../README.md").pipe(stream); 124 | }); 125 | }, 126 | "returns a 201": macros.status(201), 127 | "returns the revision": function (res) { 128 | assert.ok(res.rev); 129 | assert.match(res.rev, /^2/); 130 | } 131 | }, 132 | "with incorrect revision": { 133 | topic: function (db) { 134 | var callback = this.callback, oldRev; 135 | db.save({ _id: 'attachment-incorrect-revision' }, function (e, res) { 136 | oldRev = res.rev; 137 | db.save({_id: 'attachment-incorrect-revision', _rev:res.rev}, function (e, res) { 138 | db.saveAttachment({ 139 | id: res.id, 140 | rev: oldRev 141 | }, { 142 | name: 'foo.txt', 143 | contentType: 'text/plain', 144 | body: 'Foo!' 145 | }, callback); 146 | }); 147 | }); 148 | }, 149 | "returns a 409": macros.status(409) 150 | } 151 | }, 152 | "to a non-existing document": { 153 | topic: function (db) { 154 | db.saveAttachment('standalone-attachment', { 155 | name: 'foo.txt', 156 | contentType: 'text/plain', 157 | body: 'Foo!' 158 | }, this.callback); 159 | }, 160 | "returns a 201": macros.status(201), 161 | "returns the revision": function (res) { 162 | assert.ok(res.rev); 163 | assert.match(res.rev, /^1-/); 164 | } 165 | } 166 | }, 167 | "getting an attachment": { 168 | "when it exists": { 169 | topic: function (db) { 170 | var that = this, doc = { 171 | _id: 'attachment-getter', 172 | _attachments: { 173 | "foo.txt": { 174 | content_type: "text/plain", 175 | data: "aGVsbG8gd29ybGQ=" 176 | } 177 | } 178 | }; 179 | 180 | db.save(doc, function (e, res) { 181 | db.getAttachment('attachment-getter', 'foo.txt', that.callback); 182 | }); 183 | }, 184 | "returns a 200": macros.status(200), 185 | "returns the right mime-type in the header": function (err, res, body) { 186 | assert.equal(res.headers['content-type'], 'text/plain'); 187 | }, 188 | "returns the attachment in the body": function (err, res, body) { 189 | assert.equal(body, "hello world"); 190 | } 191 | }, 192 | "when not found": { 193 | topic: function (db) { 194 | var that = this; 195 | db.save({ _id: 'attachment-not-found' }, function (e, res) { 196 | db.getAttachment('attachment-not-found', 'foo.txt', that.callback); 197 | }); 198 | }, 199 | "returns a 404": macros.status(404) 200 | } 201 | } 202 | }) 203 | ).addBatch( 204 | macros.database({ 205 | "saving an attachment with ETag": { 206 | topic: function (db) { 207 | var id = 'attachment-incorrect-revision', 208 | that = this; 209 | 210 | db.head('attachment-incorrect-revision', function (err, _doc) { 211 | db.saveAttachment({ 212 | id: id, 213 | rev: _doc.etag, 214 | }, { 215 | name: 'etag-foo.txt', 216 | contentType: 'text/plain', 217 | body: 'FOOO!!' 218 | }, that.callback); 219 | }); 220 | }, 221 | "returns a 201": macros.status(201), 222 | "returns the revision": function (res) { 223 | assert.ok(res.rev); 224 | assert.match(res.rev, /^3/); 225 | } 226 | } 227 | }) 228 | ).addBatch( 229 | macros.database({ 230 | "getting an attachment with .pipe()": { 231 | "when it exists": { 232 | topic: function (db) { 233 | var stream = db.getAttachment('piped-attachment', 'foo.txt', this.callback); 234 | stream.pipe(fs.createWriteStream(path.join(__dirname, 'fixtures', 'README.md'))); 235 | }, 236 | "returns a 200": macros.status(200), 237 | "returns the right mime-type in the header": function (err, res, body) { 238 | assert.equal(res.headers['content-type'], 'text/plain'); 239 | }, 240 | "should write the correct attachment to disk": function (err, res, body) { 241 | assert.isNull(err); 242 | 243 | assert.equal( 244 | fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8'), 245 | fs.readFileSync(path.join(__dirname, 'fixtures', 'README.md'), 'utf8') 246 | ); 247 | } 248 | }, 249 | "when not found": { 250 | topic: function (db) { 251 | var stream = db.getAttachment('attachment-not-found', 'foo.txt'); 252 | stream.pipe(fs.createWriteStream(path.join(__dirname, 'fixtures', 'not-found.txt'))); 253 | 254 | stream.on('end', this.callback); 255 | }, 256 | "should write the error to disk": function () { 257 | var result = JSON.parse( 258 | fs.readFileSync(path.join(__dirname, 'fixtures', 'not-found.txt'), 'utf8') 259 | ); 260 | 261 | assert.equal(result.reason, 'Document is missing attachment'); 262 | } 263 | } 264 | } 265 | }) 266 | ).addBatch( 267 | macros.database({ 268 | "removeAttachment()": { 269 | "when it exists": { 270 | topic: function (db) { 271 | var that = this; 272 | db.get('attachment-getter', function (err, doc) { 273 | db.removeAttachment(doc, 'foo.txt', that.callback); 274 | }); 275 | }, 276 | "should remove the attachment": function (err, res) { 277 | assert.isNull(err); 278 | assert.ok(res.ok); 279 | } 280 | }, 281 | "when the document doesnt exist": { 282 | topic: function (db) { 283 | db.removeAttachment({ 284 | id: 'YUNOEXIST', 285 | rev: '2-6bb732ce2ecc7ac85567b444b10590b4' 286 | }, 'foo.txt', this.callback.bind(this, null)); 287 | }, 288 | "should respond with the correct error": function (_, err) { 289 | assert.isObject(err); 290 | assert.equal(err.headers.status, 409); 291 | assert.equal(err.error, 'conflict'); 292 | } 293 | } 294 | } 295 | }) 296 | ).addBatch( 297 | macros.database({ cache: true }, { 298 | "removeAttachment()": { 299 | "when it exists": { 300 | topic: function (db) { 301 | var that = this; 302 | db.get('attachment-cacher', function (err, doc) { 303 | db.removeAttachment(doc._id, 'cached/foo.txt', that.callback); 304 | }); 305 | }, 306 | "should remove the attachment": function (err, res) { 307 | assert.isNull(err); 308 | assert.ok(res.ok); 309 | } 310 | }, 311 | "when the document doesnt exist": { 312 | topic: function (db) { 313 | db.removeAttachment({ 314 | id: 'YUNOEXIST', 315 | rev: '2-6bb732ce2ecc7ac85567b444b10590b4' 316 | }, 'foo.txt', this.callback.bind(this, null)); 317 | }, 318 | "should respond with the correct error": function (_, err) { 319 | assert.isObject(err); 320 | assert.equal(err.headers.status, 409); 321 | assert.equal(err.error, 'conflict'); 322 | } 323 | } 324 | } 325 | }) 326 | ).export(module); 327 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cradle 2 | ====== 3 | 4 | [![Build Status](https://travis-ci.org/flatiron/cradle.svg?branch=master)](https://travis-ci.org/flatiron/cradle) 5 | [![Dependency Status](https://david-dm.org/flatiron/cradle.svg)](https://david-dm.org/flatiron/cradle) 6 | 7 | A high-level, caching, CouchDB client for Node.js 8 | 9 | introduction 10 | ------------ 11 | 12 | Cradle is an asynchronous javascript client for [CouchDB](http://couchdb.apache.org). 13 | It is somewhat higher-level than most other CouchDB clients, requiring a little less knowledge of CouchDB's REST API. 14 | Cradle also has built-in write-through caching, giving you an extra level of speed, and making document _updates_ and _deletion_ easier. 15 | Cradle was built from the love of CouchDB and Node.js, and tries to make the most out of this wonderful marriage of technologies. 16 | 17 | philosophy 18 | ---------- 19 | 20 | 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. 21 | 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. 22 | 23 | synopsis 24 | -------- 25 | 26 | ``` js 27 | var cradle = require('cradle'); 28 | var db = new(cradle.Connection)().database('starwars'); 29 | 30 | db.get('vader', function (err, doc) { 31 | doc.name; // 'Darth Vader' 32 | assert.equal(doc.force, 'dark'); 33 | }); 34 | 35 | db.save('skywalker', { 36 | force: 'light', 37 | name: 'Luke Skywalker' 38 | }, function (err, res) { 39 | if (err) { 40 | // Handle error 41 | } else { 42 | // Handle success 43 | } 44 | }); 45 | ``` 46 | 47 | installation 48 | ------------ 49 | 50 | ``` bash 51 | $ npm install cradle 52 | ``` 53 | 54 | API 55 | --- 56 | 57 | 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. 58 | 59 | ### Opening a connection ### 60 | 61 | ``` js 62 | new(cradle.Connection)('http://living-room.couch', 5984, { 63 | cache: true, 64 | raw: false, 65 | forceSave: true, 66 | request: { 67 | //Pass through configuration to `request` library for all requests on this connection. 68 | } 69 | }); 70 | ``` 71 | 72 | _Defaults to `127.0.0.1:5984`_ 73 | 74 | Note that you can also use `cradle.setup` to set a global configuration: 75 | 76 | ``` js 77 | cradle.setup({ 78 | host: 'living-room.couch', 79 | cache: true, 80 | raw: false, 81 | forceSave: true 82 | }); 83 | 84 | var c = new(cradle.Connection), 85 | cc = new(cradle.Connection)('173.45.66.92'); 86 | ``` 87 | 88 | ### creating a database ### 89 | 90 | ``` js 91 | var db = c.database('starwars'); 92 | db.create(function(err){ 93 | /* do something if there's an error */ 94 | }); 95 | ``` 96 | 97 | #### checking for database existence #### 98 | 99 | You can check if a database exists with the `exists()` method. 100 | 101 | ``` js 102 | db.exists(function (err, exists) { 103 | if (err) { 104 | console.log('error', err); 105 | } else if (exists) { 106 | console.log('the force is with you.'); 107 | } else { 108 | console.log('database does not exists.'); 109 | db.create(); 110 | /* populate design documents */ 111 | } 112 | }); 113 | ``` 114 | 115 | ### destroy a database ### 116 | 117 | ``` js 118 | db.destroy(cb); 119 | ``` 120 | 121 | ### set revisions limit ### 122 | Each database has a limit on how many revisions of a document can save. 123 | 124 | Default value: **1000** 125 | 126 | ``` js 127 | db.maxRevisions(4, function(err, res) { 128 | if (err) { 129 | console.log('error', err); 130 | } else { 131 | console.log('Revisions limit has changed'); 132 | } 133 | }); 134 | ``` 135 | 136 | ### get revisions limit ### 137 | You can also get the current revisions limit 138 | 139 | ``` js 140 | db.maxRevisions(function(err, limit) { 141 | if (err) { 142 | console.log('error', err); 143 | } else { 144 | console.log('Revisions limit is: '+limit); 145 | } 146 | }); 147 | ``` 148 | 149 | 150 | ### fetching a document _(GET)_ ### 151 | 152 | ``` js 153 | db.get('vader', function (err, doc) { 154 | console.log(doc); 155 | }); 156 | ``` 157 | 158 | > If you want to get a specific revision for that document, you can pass it as the 2nd parameter to `get()`. 159 | 160 | Cradle is also able to fetch multiple documents if you have a list of ids, just pass an array to `get`: 161 | 162 | ``` js 163 | db.get(['luke', 'vader'], function (err, doc) { ... }); 164 | ``` 165 | 166 | ### Querying a view ### 167 | 168 | ``` js 169 | db.view('characters/all', function (err, res) { 170 | res.forEach(function (row) { 171 | console.log("%s is on the %s side of the force.", row.name, row.force); 172 | }); 173 | }); 174 | ``` 175 | 176 | You can access the key and value of the response with forEach using two parameters. An optional third parameter will return the id like this example. 177 | 178 | ``` js 179 | db.view('characters/all', function (err, res) { 180 | res.forEach(function (key, row, id) { 181 | console.log("%s has view key %s.", row.name, key); 182 | }); 183 | }); 184 | ``` 185 | 186 | To use [View Generation Options](http://wiki.apache.org/couchdb/HTTP_view_API#View_Generation_Options) you can use the view Method with three parameters (viewname, options, callback): 187 | 188 | ``` js 189 | db.view('characters/all', {group: true, reduce: true} , function (err, res) { 190 | res.forEach(function (row) { 191 | console.log("%s is on the %s side of the force.", row.name, row.force); 192 | }); 193 | }); 194 | ``` 195 | 196 | #### Querying a row with a specific key #### 197 | Lets suppose that you have a design document that you've created: 198 | 199 | ``` js 200 | db.save('_design/user', { 201 | views: { 202 | byUsername: { 203 | map: 'function (doc) { if (doc.resource === "User") { emit(doc.username, doc) } }' 204 | } 205 | } 206 | }); 207 | ``` 208 | 209 | In CouchDB you could query this view directly by making an HTTP request to: 210 | 211 | ``` 212 | /_design/user/_view/byUsername/?key="luke" 213 | ``` 214 | 215 | In `cradle` you can make this same query by using the `.view()` database function: 216 | 217 | ``` js 218 | db.view('user/byUsername', { key: 'luke' }, function (err, doc) { 219 | console.dir(doc); 220 | }); 221 | ``` 222 | 223 | #### Querying a view with an array key 224 | 225 | Say you create view for cars that has an array key with make and model 226 | ``` js 227 | db.save('_design/cars', { 228 | views: { 229 | byMakeAndModel: { 230 | map: function (doc) { 231 | if (doc.resource === 'Car' && doc.make && doc.model) { 232 | var key = [doc.make, doc.model] 233 | emit(key, doc) 234 | } 235 | } 236 | } 237 | } 238 | }) 239 | ``` 240 | If you want all the cars made by *Ford* with a model name between *Rav4* and later (alphabetically sorted). 241 | In CouchDB you could query this view directly by making an HTTP request to: 242 | ``` 243 | /_design/cars/_view/byMakeAndModel/?startkey=["Ford"]&endkey=["Ford", "\u9999"] 244 | ``` 245 | 246 | In `cradle` you can make this same query by using the `.view()` database function with `startkey` and `endkey` options. 247 | 248 | ``` js 249 | var util = require('util') 250 | var opts = { 251 | startkey: ['Ford'], 252 | endkey: ['Ford', '\u9999'] 253 | } 254 | db.view('cars/', opts, function (err, docs) { 255 | if (err) { 256 | util.error(err) 257 | return 258 | } 259 | util.debug(docs) 260 | }); 261 | ``` 262 | In the options object you can also optionally specify whether or not to `group` and `reduce` the output. In this example `reduce` must be false since there is no reduce function defined for the `cars/byMakeAndModel`. With grouping and reducing the options object would look like: 263 | ``` js 264 | var opts = { 265 | startkey: ['Ford'], 266 | endkey: ['Ford', '\u9999'], 267 | group: true, 268 | reduce: true 269 | } 270 | ``` 271 | 272 | ### creating/updating documents ### 273 | 274 | In general, document creation is done with the `save()` method, while updating is done with `merge()`. 275 | 276 | #### creating with an id _(PUT)_ #### 277 | 278 | ``` js 279 | db.save('vader', { 280 | name: 'darth', force: 'dark' 281 | }, function (err, res) { 282 | // Handle response 283 | }); 284 | ``` 285 | 286 | #### creating without an id _(POST)_ #### 287 | 288 | ``` js 289 | db.save({ 290 | force: 'dark', name: 'Darth' 291 | }, function (err, res) { 292 | // Handle response 293 | }); 294 | ``` 295 | 296 | #### updating an existing document with the revision #### 297 | 298 | ``` js 299 | db.save('luke', '1-94B6F82', { 300 | force: 'dark', name: 'Luke' 301 | }, function (err, res) { 302 | // Handle response 303 | }); 304 | ``` 305 | 306 | 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. 307 | 308 | If you only want to update one or more attributes, and leave the others untouched, you can use the `merge()` method: 309 | 310 | ``` js 311 | db.merge('luke', {jedi: true}, function (err, res) { 312 | // Luke is now a jedi, 313 | // but remains on the dark side of the force. 314 | }); 315 | ``` 316 | 317 | 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. 318 | 319 | #### bulk insertion #### 320 | 321 | If you want to insert more than one document at a time, for performance reasons, you can pass an array to `save()`: 322 | 323 | ``` js 324 | db.save([ 325 | { name: 'Yoda' }, 326 | { name: 'Han Solo' }, 327 | { name: 'Leia' } 328 | ], function (err, res) { 329 | // Handle response 330 | }); 331 | ``` 332 | 333 | #### creating views #### 334 | 335 | Here we create a design document named 'characters', with two views: 'all' and 'darkside'. 336 | 337 | ``` js 338 | db.save('_design/characters', { 339 | all: { 340 | map: function (doc) { 341 | if (doc.name) emit(doc.name, doc); 342 | } 343 | }, 344 | darkside: { 345 | map: function (doc) { 346 | if (doc.name && doc.force == 'dark') { 347 | emit(null, doc); 348 | } 349 | } 350 | } 351 | }); 352 | ``` 353 | 354 | These views can later be queried with `db.view('characters/all')`, for example. 355 | 356 | Here we create a temporary view. WARNING: do not use this in production as it is 357 | extremely slow (use it to test views). 358 | 359 | ``` js 360 | db.temporaryView({ 361 | map: function (doc) { 362 | if (doc.color) emit(doc._id, doc); 363 | } 364 | }, function (err, res) { 365 | if (err) console.log(err); 366 | console.log(res); 367 | }); 368 | ``` 369 | Note: If you must use [View Generation Options](http://wiki.apache.org/couchdb/HTTP_view_API#View_Generation_Options) on your temporary view you can use the three parameter version of the temporaryView() Method - similar to the one described above. 370 | 371 | ### creating validation ### 372 | 373 | when saving a design document, cradle guesses you want to create a view, mention views explicitly to work around this. 374 | 375 | ``` js 376 | db.save('_design/laws', { 377 | views: {}, 378 | validate_doc_update: 379 | function (newDoc, oldDoc, usrCtx) { 380 | if (! /^(light|dark|neutral)$/.test(newDoc.force)) 381 | throw({forbidden: {error: "invalid value", reason: "force must be dark, light, or neutral"}}) 382 | } 383 | } 384 | }); 385 | ``` 386 | 387 | ### removing documents _(DELETE)_ ### 388 | 389 | To remove a document, you call the `remove()` method, passing the latest document revision. 390 | 391 | ``` js 392 | db.remove('luke', '1-94B6F82', function (err, res) { 393 | // Handle response 394 | }); 395 | ``` 396 | 397 | 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. 398 | 399 | ### update handlers ### 400 | 401 | Update handlers can be used by calling the `update()` method, specifying the update handler name, and optionally the document id, the query object and the document body object. Only the update handler name is a required function parameter. Note that CouchDB is able to parse query options only if the URI-encoded length is less than 8197 characters. Use the body parameter for larger objects. 402 | 403 | ``` js 404 | db.update('my_designdoc/update_handler_name', 'luke', undefined, { my_param: false }, function (err, res) { 405 | // Handle the response, specified by the update handler 406 | }); 407 | ``` 408 | 409 | Connecting with authentication and SSL 410 | -------------------------------------- 411 | 412 | ``` js 413 | var connection = new(cradle.Connection)('https://couch.io', 443, { 414 | auth: { username: 'john', password: 'fha82l' } 415 | }); 416 | ``` 417 | 418 | or providing a self signed CA certificate 419 | 420 | ``` js 421 | var connection = new(cradle.Connection)('https://couch.io', 443, { 422 | auth: { username: 'john', password: 'fha82l' }, 423 | ca: fs.readFileSync('path_to_self_signed_ca.crt') 424 | }); 425 | ``` 426 | 427 | or 428 | 429 | ``` js 430 | var connection = new(cradle.Connection)('couch.io', 443, { 431 | secure: true, 432 | auth: { username: 'john', password: 'fha82l' } 433 | }); 434 | ``` 435 | 436 | Retry on Connection Issues 437 | -------------------------- 438 | 439 | For a unreliable connection, you can have non-streaming queries automatically retry: 440 | 441 | ``` js 442 | var connection = new(cradle.Connection)('couch.io', 443, { 443 | retries: 3, 444 | retryTimeout: 30 * 1000 445 | }); 446 | ``` 447 | 448 | Changes API 449 | ----------- 450 | 451 | For a one-time `_changes` query, simply call `db.changes` with a callback: 452 | 453 | ``` js 454 | db.changes(function (err, list) { 455 | list.forEach(function (change) { console.log(change) }); 456 | }); 457 | ``` 458 | 459 | Or if you want to see changes since a specific sequence number: 460 | 461 | ``` js 462 | db.changes({ since: 42 }, function (err, list) { 463 | ... 464 | }); 465 | ``` 466 | 467 | The callback will receive the list of changes as an *Array*. If you want to include 468 | the affected documents, simply pass `include_docs: true` in the options. 469 | 470 | ### Streaming # 471 | 472 | You can also *stream* changes, by calling `db.changes` without the callback. This API uses the **excellent** [follow][0] library from [IrisCouch][1]: 473 | 474 | ``` js 475 | var feed = db.changes({ since: 42 }); 476 | 477 | feed.on('change', function (change) { 478 | console.log(change); 479 | }); 480 | ``` 481 | 482 | 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]. 483 | 484 | Attachments 485 | ----------- 486 | Cradle supports writing, reading, and removing attachments. The read and write operations can be either buffered or streaming 487 | ## Writing ## 488 | You can buffer the entire attachment body and send it all at once as a single request. The callback function will fire after the attachment upload is complete or an error occurs 489 | 490 | **Syntax** 491 | ```js 492 | db.saveAttachment(idData, attachmentData, callbackFunction) 493 | ``` 494 | **Example** 495 | Say you want to save a text document as an attachment with the name 'fooAttachment.txt' and the content 'Foo document text' 496 | ``` js 497 | var doc = 498 | var id = doc._id 499 | var rev = doc._rev 500 | var idAndRevData = { 501 | id: id, 502 | rev: rev 503 | } 504 | var attachmentData = { 505 | name: 'fooAttachment.txt', 506 | 'Content-Type': 'text/plain', 507 | body: 'Foo document text' 508 | } 509 | db.saveAttachment(idAndRevData, attachmentData, function (err, reply) { 510 | if (err) { 511 | console.dir(err) 512 | return 513 | } 514 | console.dir(reply) 515 | }) 516 | ``` 517 | 518 | 519 | ### Streaming ### 520 | You can use a read stream to upload the attachment body rather than buffering the entire body first. The callback function will fire after the streaming upload completes or an error occurs 521 | 522 | **Syntax** 523 | ```js 524 | var doc = savedDoc // 525 | var id = doc._id 526 | var rev = doc._rev 527 | var idAndRevData = { 528 | id: id, 529 | rev: rev 530 | } 531 | var attachmentData = { 532 | name: attachmentName // something like 'foo.txt' 533 | 'Content-Type': attachmentMimeType // something like 'text/plain', 'application/pdf', 'image/jpeg' etc. 534 | } 535 | var readStream = fs.createReadStream('/path/to/file/') 536 | var writeStream = db.saveAttachment(idData, attachmentData, callbackFunction) 537 | readStream.pipe(writeStream) 538 | ``` 539 | When the streaming upload is complete the callback function will fire 540 | 541 | 542 | **Example** 543 | Attach a pdf file with the name 'bar.pdf' located at path './data/bar.pdf' to an existing document 544 | 545 | ```js 546 | var fs = require('fs') 547 | 548 | // this document should already be saved in the couchdb database 549 | var doc = { 550 | _id: 'fooDocumentID', 551 | _rev: 'fooDocumentRev' 552 | } 553 | 554 | // the reference to the document in couch db that will be passed to the saveAttachment method 555 | var idData = { 556 | id: doc._id, 557 | rev: doc._rev 558 | } 559 | 560 | // Read the file that you want to attach 561 | var filePath = 'data/bar.pdf' 562 | var readStream = fs.createReadStream(filePath) 563 | 564 | var attachmentName = 'bar.pdf' // this is the filename that will be used in couchdb. It can be different from your source filename if desired 565 | 566 | // note that there is no body field here since we are streaming the upload 567 | var attachmentData = { 568 | name: attachmentName, 569 | 'Content-Type': 'application/pdf' 570 | } 571 | 572 | var writeStream = db.saveAttachment(idData, attachmentData, function (err, reply) { 573 | if (err) { 574 | console.dir(err) 575 | return 576 | } 577 | console.dir(reply) 578 | }) 579 | readStream.pipe(writeStream) 580 | ``` 581 | 582 | 583 | ## Reading ## 584 | 585 | 586 | ### Buffered 587 | You can buffer the entire attachment and receive it all at once. The callback function will fire after the download is complete or an error occurs. The second parameter in the callback will be the binary data of the attachment 588 | 589 | **Syntax** 590 | ```js 591 | db.getAttachment(documentID, attachmentName, callbackFunction) 592 | ``` 593 | **Example** 594 | Say you want to read back an attachment that was saved with the name 'foo.txt' 595 | ```js 596 | var doc = 597 | var id = doc._id 598 | var attachmentName = 'foo.txt' 599 | db.getAttachment(id, attachmentName, function (err, reply) { 600 | if (err) { 601 | console.dir(err) 602 | return 603 | } 604 | console.dir(reply) 605 | }) 606 | ``` 607 | 608 | ### Streaming 609 | You can stream the attachment as well. If the attachment is large it can be useful to stream it to limit memory consumption. The callback function will fire once the download stream is complete. Note that there is only a single error parameter passed to the callback function. The error is null is no errors occured or an error object if there was an error downloading the attachment. There is no second parameter containing the attachment data like in the buffered read example 610 | 611 | **Syntax** 612 | ```js 613 | var readStream = db.getAttachment(documentID, attachmentName, callbackFunction) 614 | ``` 615 | 616 | **Example** 617 | Say you want to read back an attachment that was saved with the name 'foo.txt'. However the attachment foo.txt is very large so you want to stream it to disk rather than buffer the entire file into memory 618 | ```js 619 | var doc = 620 | var id = doc._id 621 | var attachmentName = 'foo.txt' 622 | var downloadPath = path.join(__dirname, 'foo_download.txt') 623 | var writeStream = fs.createWriteStream(downloadPath) 624 | var readStream = db.getAttachment('piped-attachment', 'foo.txt', function (err) { // note no second reply paramter 625 | if (err) { 626 | console.dir(err) 627 | return 628 | } 629 | console.dir('download completed and written to file on disk at path', downloadPath) 630 | }) 631 | readStream.pipe(writeStream) 632 | ``` 633 | ## Removing 634 | You can remove uploaded attachments with a _id and an attachment name 635 | 636 | **Syntax** 637 | ```js 638 | db.removeAttachment(documentID, attachmentName, callbackFunction) 639 | ``` 640 | **Example** 641 | Say you want to remove an attachment that was saved with the name 'foo.txt' 642 | ```js 643 | var doc = 644 | var id = doc._id 645 | var attachmentName = 'foo.txt' 646 | db.removeAttachment(id, attachmentName, function (err, reply) { 647 | if (err) { 648 | console.dir(err) 649 | return 650 | } 651 | console.dir(reply) 652 | }) 653 | ``` 654 | Other API methods 655 | ----------------- 656 | 657 | ### CouchDB Server level ### 658 | 659 | ``` js 660 | new(cradle.Connection)().* 661 | ``` 662 | 663 | - `databases()`: Get list of databases 664 | - `config()`: Get server config 665 | - `info()`: Get server information 666 | - `stats()`: Statistics overview 667 | - `activeTasks()`: Get list of currently active tasks 668 | - `uuids(count)`: Get _count_ list of UUIDs 669 | - `replicate(options)`: Replicate a database. 670 | 671 | ### database level ### 672 | 673 | ``` js 674 | new(cradle.Connection)().database('starwars').* 675 | ``` 676 | 677 | - `info()`: Database information 678 | - `all()`: Get all documents 679 | - `compact()`: Compact database 680 | - `viewCleanup()`: Cleanup old view data 681 | - `replicate(target, options)`: Replicate this database to `target`. 682 | - `maxRevisions(function(error, limit))`: Get revision limit 683 | - `maxRevisions(rev, function(error, result))`: Set revision limit 684 | 685 | 686 | ### cache API ### 687 | 688 | When cache is enabled (default is true), a document is loaded into cradle's cache when it's retrieved or saved. In the event you wish to keep caching enabled, but invalidate specific items - such as those which may have been updated elsewhere. You can use the API below. 689 | 690 | **HAS** 691 | ```js 692 | db.cache.has('docid'); //returns true if exists, false if not 693 | ``` 694 | 695 | **GET** 696 | ```js 697 | db.cache.get('docid'); //returns the document from the cache 698 | ``` 699 | 700 | **PURGE** 701 | ```js 702 | db.cache.purge('docid'); //remove this item from the cache 703 | ``` 704 | 705 | **SAVE** 706 | ```js 707 | db.cache.save('docid', doc); //saves the provided document into the cache 708 | ``` 709 | 710 | **Example** 711 | This is an example from an application using express to receive a post request when a documentid has been updated. 712 | ```js 713 | app.post('/dbcache/:id', function (req, res) { 714 | if(db.cache.has(req.params.id)) { 715 | db.cache.purge(req.params.id); 716 | res.send({ status:"ok", id: req.params.id, action: 'deleted'}); 717 | } 718 | else { 719 | res.send({ status:"not found", id: req.params.id, action: "none"}, 404); 720 | } 721 | }); 722 | ``` 723 | 724 | 725 | [0]: https://github.com/iriscouch/follow 726 | [1]: http://iriscouch.com 727 | 728 | 729 | Testing 730 | ------- 731 | 732 | After cloning the repo and installing all dependencies (using `npm install`) you can run all tests using [vows](http://vowsjs.org): 733 | 734 | ``` 735 | $ node test/helpers/seed.js 736 | $ vows --spec 737 | ``` 738 | --------------------------------------------------------------------------------