├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── lib ├── db.js └── server.js ├── package.json └── test ├── testDb.js └── testServer.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git* 2 | .DS_Store 3 | .editorconfig 4 | .jshintrc 5 | .travis.yml 6 | coverage/ 7 | test/ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | - '5.0' 5 | deploy: 6 | provider: npm 7 | email: code@tangledfruit.com 8 | api_key: 9 | secure: XVGe3lHt06surAA5+WayQ1owtD4/rZ8la64YcTwj0+hJsNmAHIeXpHTDsRULS17wkCidXgOFsxt2ZLbbdGC5/rClUz23PfVnSt1D5m3ctuXglqiqdHfgEGwiYnQOzCDoSzUkFFs14jxzIRGUVE1H0Sfbx31g688hX2uoHI7nsHJFvEF5sHnbIkF28dQKCnDRilE98ZioX9uQCKZimdfBZoAxjgaiKsf1w5SVt/ozzFHUu4tArYyOiGm5cyMOy6KVLXbEPcPa7Be5YlCB379+F8CV7iMrhd7kxx7NnDuCS5me+Dbjv5TDZT7kkL4SHR38ySQUmd7QIrGP3Ta5k3RevGPuQGbxpGtVY9ObZAAlZtrOSW3W1EKld1/bg/I5XSMTXE0939SYG9SGPo55Bi7lyv1esTY1P8kp1tcuPrWE9ZnCOFfzc23C32/1I5/Z7/+GDVMk0JPW4OaSFMfJ9avamN665jKnE5czttfqNksv1WxR2OkxlsfqziE7/Vx7i55dbp/qIAfudxtOZNLm/5NFPTfLAOrhC2uAZNIiXXsQTjDmM3GnmFi9oirclc2JhGElSewoxTjRRRn4LRVe9NOH8scAEGoZwWm+cnEDd8f4Z5UAbbHS7Hd6MWToZM3b/AJt5flZpMoRWjytiyeBMTS8lEgYuR+tX2/dJ1yt+/F152M= 10 | on: 11 | tags: true 12 | repo: tangledfruit/rx-couch 13 | services: 14 | - couchdb 15 | script: "npm run-script test-travis" 16 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 tangledfruit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rx-couch [![Build Status](https://travis-ci.org/tangledfruit/rx-couch.svg?branch=master)](https://travis-ci.org/tangledfruit/rx-couch) [![Coverage Status](https://coveralls.io/repos/tangledfruit/rx-couch/badge.svg?branch=master&service=github)](https://coveralls.io/github/tangledfruit/rx-couch?branch=master) [![js-semistandard-style](https://img.shields.io/badge/code%20style-semistandard-brightgreen.svg?style=flat-square)](https://github.com/Flet/semistandard) 2 | 3 | RxJS-flavored APIs for CouchDB 4 | 5 | **IMPORTANT:** This library only supports RxJS 4.x. 6 | 7 | **Looking for RxJS 5.0+ support?** Try [rxjs-couch](https://github.com/tangledfruit/rxjs-couch). (Same name but replace 'rx' with 'rxjs'.) 8 | 9 | ## Installation 10 | 11 | ### NPM 12 | 13 | ```sh 14 | npm install --save rx-couch 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | const RxCouch = require('rx-couch'); 21 | 22 | const server = new RxCouch('http://my.host:5984'); 23 | 24 | // List all databases on the server. 25 | // http://docs.couchdb.org/en/latest/api/server/common.html#all-dbs 26 | server.allDatabases() 27 | .subscribe(databases => console.log(databases)); 28 | // -> ['_replicator', '_users', 'my-database', etc...] 29 | 30 | // Create a database. By default, this will ignore 412 errors on the assumption 31 | // that this means "database already exists." 32 | // http://docs.couchdb.org/en/latest/api/database/common.html#put--db 33 | server.createDatabase('test-rx-couch') 34 | .subscribeOnCompleted(); // fires when done 35 | 36 | // Create a database and fail if the database already exists. 37 | // http://docs.couchdb.org/en/latest/api/database/common.html#put--db 38 | server.createDatabase('test-rx-couch', {failIfExists: true}) 39 | .subscribeOnError(); // In this example, an error event would be sent. 40 | 41 | // Delete a database. 42 | // http://docs.couchdb.org/en/latest/api/database/common.html#delete--db 43 | server.deleteDatabase('some-other-database') 44 | .subscribeOnCompleted(); // fires when done 45 | 46 | // Replicate a database. 47 | // http://docs.couchdb.org/en/latest/api/server/common.html#replicate 48 | server.replicate({source: 'db1', target: 'db2'}) 49 | .subscribe(status => console.log(status)); 50 | // -> {history: [...], ok: true, etc...} 51 | 52 | // Create a database object to interact with a single database on the server. 53 | // WARNING: Does not create the database. See .createDatabase above. 54 | const db = server.db('test-rx-couch'); 55 | 56 | // Create a new document and let CouchDB assign an ID. 57 | // http://docs.couchdb.org/en/latest/api/database/common.html#post--db 58 | db.put({foo: 'bar'}) 59 | .subscribe(result => console.log(result)); 60 | // -> {id: '(random)', ok: true, rev: '1-(random)'} 61 | 62 | // Create a new document using an ID that I choose. 63 | // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 64 | db.put({_id: 'testing123', foo: 'bar'}) 65 | .subscribe(result => console.log(result)); 66 | // -> {id: 'testing123', ok: true, rev: '1-(random)'} 67 | 68 | // Update an existing document, replacing existing value 69 | // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 70 | db.put({_id: 'testing123', _rev: '1-existingRevId', foo: 'baz'}) 71 | .subscribe(result => console.log(result)); 72 | // -> {id: 'testing123', ok: true, rev: '2-(random)'} 73 | 74 | // Update an existing document, merging new content into existing value. 75 | db.update({_id: 'testing123', flip: true}) 76 | .subscribe(result => console.log(result)); 77 | // -> {id: 'testing123', ok: true, 'rev': '3-(random)'} 78 | 79 | // Update an existing document, replacing existing value, but avoiding 80 | // write if new value exactly matches existing. 81 | db.replace({_id: 'testing123', flip: true}) 82 | .subscribe(result => console.log(result)); 83 | // -> {id: 'testing123', ok: true, rev: '3-(random)', noop: true} 84 | 85 | // Get the current value of an existing document. 86 | // http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid 87 | db.get('testing123') 88 | .subscribe(result => console.log(result)); 89 | // -> {_id: 'testing123', _rev: '3-(random)', foo: 'baz', flip: true} 90 | 91 | // Get the value of an existing document with query options. 92 | // All options described under query parameters below are supported: 93 | // http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid 94 | db.get('testing123', {rev: '1-existingRevId'}) 95 | .subscribe(result => console.log(result)); 96 | // -> {_id: 'testing123', _rev: '1-(random)', foo: 'baz'} 97 | 98 | // Observe the value of an existing document over time. 99 | // Returns the current document value soon after the call is issued 100 | // and monitors the value until the subscription is terminated. 101 | // Use this sparingly; having many of these open at once could lead to 102 | // unacceptable server load. 103 | 104 | db.observe('testing123') 105 | .subscribe(result => console.log(result)); 106 | // -> one or more results of the form 107 | // {_id: 'testing123', _rev: '1-(random)', foo: 'baz'} 108 | 109 | // Get information about several documents at once. 110 | // All options described under query parameters below are supported: 111 | // http://docs.couchdb.org/en/latest/api/database/bulk-api.html#get--db-_all_docs 112 | db.allDocs({startkey: 'testing123'}) 113 | .subscribe(result => console.log(result)); 114 | // -> {offset: 0, rows: {...}, total_rows: 5} 115 | 116 | // Delete an existing document. Both arguments (doc ID and rev ID) are required. 117 | // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 118 | db.delete('testing123', '3-latestRevId') 119 | .subscribe(result => console.log(result)); 120 | // -> {id: 'testing123', ok: true, rev: '4-(random)'} 121 | 122 | // Replicate from another database into this database. 123 | // http://docs.couchdb.org/en/latest/api/server/common.html#replicate 124 | db.replicateFrom({source: 'db1'}) 125 | .subscribe(status => console.log(status)); 126 | // -> {history: [...], ok: true, etc...} 127 | 128 | // Monitor changes to the database. 129 | // http://docs.couchdb.org/en/latest/api/database/changes.html 130 | db.changes({include_docs: true}) 131 | .subscribe(result => console.log(result)); 132 | // -> any number of result objects of the form (one per document): 133 | // { 134 | // "changes": [ 135 | // { 136 | // "rev": "2-7051cbe5c8faecd085a3fa619e6e6337" 137 | // } 138 | // ], 139 | // "id": "6478c2ae800dfc387396d14e1fc39626", 140 | // "seq": 6 141 | // } 142 | // 143 | // The exact form of the responses will vary depending on the 144 | // options specified. 145 | // 146 | // If feed: "longpoll" appears in the options object, the changes 147 | // feed is monitored continuously until the subscription is dropped. 148 | // It is an error to use feed: "continuous". 149 | 150 | ``` 151 | 152 | If any HTTP errors occur, they will be reported via `onError` notification on 153 | the Observable using the [HTTP error object from rx-fetch](https://github.com/tangledfruit/rx-fetch#http-error-object). 154 | 155 | ## License 156 | 157 | MIT 158 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/server'); 2 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Rx = require('rx'); 4 | const rxFetch = require('rx-fetch'); 5 | const deepEqual = require('deep-eql'); 6 | const deepMerge = require('deepmerge'); 7 | const shallowCopy = require('shallow-copy'); 8 | const querystring = require('querystring'); 9 | 10 | let db = module.exports = function (dbUrl, server) { 11 | // Since this function is only accessible internally, we assume that dbUrl 12 | // has been validated already. 13 | 14 | this._dbUrl = dbUrl; 15 | this._server = server; 16 | this._changesFetchCount = 0; 17 | }; 18 | 19 | const makeDocUrl = (dbUrl, docId, queryOptions) => 20 | dbUrl + '/' + docId + (queryOptions ? ('?' + querystring.stringify(queryOptions)) : ''); 21 | 22 | /** 23 | * Retrieve the value of an existing document. 24 | * 25 | * If an options object is provided, it is converted to query options. 26 | * 27 | * Returns an Observable which will fire exactly once on success. The result will 28 | * contain the CouchDB response object decoded from JSON. 29 | * 30 | * See http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid 31 | * for valid request options and response objects. 32 | */ 33 | 34 | db.prototype.get = function (id, options) { 35 | if (!id) { 36 | throw new Error('rxCouch.db.get: missing document ID'); 37 | } 38 | 39 | if (typeof (id) !== 'string' || id.length === 0) { 40 | throw new Error('rxCouch.db.get: invalid document ID'); 41 | } 42 | 43 | const getUrl = makeDocUrl(this._dbUrl, id, options); 44 | 45 | return rxFetch(getUrl, {headers: {Accept: 'application/json'}}).json(); 46 | }; 47 | 48 | /** 49 | * Create a new document or update an existing document. Pass in a single Object 50 | * which will be the new document value. It should normally contain _id and _rev 51 | * fields, specifying the document ID and existing revision ID to replace. Omit 52 | * _rev when creating a new document. Omit _id when creating a new document if 53 | * you want CouchDB to assign a document ID for you. 54 | * 55 | * Returns an Observable which will fire exactly once on success. The result will 56 | * contain the CouchDB response object decoded from JSON 57 | * (i.e. {id: "document ID", ok: true, rev: "new revision ID"}. 58 | * 59 | * See http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 60 | * and http://docs.couchdb.org/en/latest/api/database/common.html#post--db. 61 | */ 62 | 63 | db.prototype.put = function (value) { 64 | if (!value) { 65 | throw new Error('rxCouch.db.put: missing document value'); 66 | } 67 | 68 | if (typeof (value) !== 'object') { 69 | throw new Error('rxCouch.db.put: invalid document value'); 70 | } 71 | 72 | let valueCopy = shallowCopy(value); 73 | 74 | const docId = valueCopy._id; 75 | const requestMethod = docId ? 'put' : 'post'; 76 | const postUrl = docId ? makeDocUrl(this._dbUrl, docId) : this._dbUrl; 77 | 78 | let options = { 79 | method: requestMethod, 80 | headers: { 81 | Accept: 'application/json', 82 | 'Content-Type': 'application/json' 83 | } 84 | }; 85 | 86 | if (valueCopy._rev) { 87 | options.headers['If-Match'] = valueCopy._rev; 88 | delete valueCopy._rev; 89 | } 90 | 91 | delete valueCopy._id; 92 | 93 | options.body = JSON.stringify(valueCopy); 94 | 95 | return rxFetch(postUrl, options).json(); 96 | }; 97 | 98 | /** 99 | * Update an existing document or create a new document with this content. 100 | * Pass in a single Object which will be the new content to be applied 101 | * to this document. It must contain an _id field, specifying the document ID 102 | * to update or create. It must *NOT* contain a _rev field, as the revision ID 103 | * will be automatically populated from the existing document (if any). 104 | * 105 | * Conceptually, this function does get -> merge content -> put atomically, although 106 | * it is possible that the operation will fail if a competing update happens 107 | * in the interim. 108 | * 109 | * Returns an Observable which will fire exactly once on success. The result will 110 | * contain the CouchDB response object decoded from JSON 111 | * (i.e. {id: "document ID", ok: true, rev: "new revision ID"}. 112 | */ 113 | 114 | db.prototype.update = function (value) { 115 | if (!value) { 116 | throw new Error('rxCouch.db.update: missing document value'); 117 | } 118 | 119 | if (typeof (value) !== 'object') { 120 | throw new Error('rxCouch.db.update: invalid document value'); 121 | } 122 | 123 | const id = value._id; 124 | if (!id) { 125 | throw new Error('rxCouch.db.update: _id is missing'); 126 | } 127 | 128 | if (value._rev) { 129 | throw new Error('rxCouch.db.update: _rev is not allowed'); 130 | } 131 | 132 | return this.get(id) 133 | .catch(err => { 134 | // If no such document, create an empty placeholder document. 135 | // Otherwise, just rethrow the error. 136 | 137 | /* istanbul ignore else */ 138 | if (err.message.match(/HTTP Error 404/)) { 139 | return Rx.Observable.just({_id: id}); 140 | } else { 141 | return Rx.Observable.throw(err); 142 | } 143 | }) 144 | .flatMapLatest(oldValue => { 145 | // Apply new content as a delta against existing doc value, 146 | // but only if there is an actual change. 147 | 148 | const newValue = deepMerge(oldValue, value); 149 | 150 | if (deepEqual(oldValue, newValue)) { 151 | return Rx.Observable.just({ 152 | id: id, 153 | ok: true, 154 | rev: oldValue._rev, 155 | noop: true 156 | }); 157 | } else { 158 | return this.put(deepMerge(oldValue, value)); 159 | } 160 | }); 161 | }; 162 | 163 | /** 164 | * Replace an existing document value or create a new document with this content. 165 | * Pass in a single Object which will be the new content to be used as this 166 | * document's new value. It must contain an _id field, specifying the document ID 167 | * to update or create. It must *NOT* contain a _rev field, as the revision ID 168 | * will be automatically populated from the existing document (if any). 169 | * 170 | * If the document's value is exactly the same as the existing content, do not 171 | * update. (A typical use case: If writes to your database are priced expensively 172 | * and you want to avoid them.) 173 | * 174 | * Conceptually, this function does get -> replace content -> put atomically, 175 | * although it is possible that the operation will fail if a competing update happens 176 | * in the interim. 177 | * 178 | * Returns an Observable which will fire exactly once on success. The result will 179 | * contain the CouchDB response object decoded from JSON 180 | * (i.e. {id: "document ID", ok: true, rev: "new revision ID"}. 181 | */ 182 | 183 | db.prototype.replace = function (value) { 184 | if (!value) { 185 | throw new Error('rxCouch.db.replace: missing document value'); 186 | } 187 | 188 | if (typeof (value) !== 'object') { 189 | throw new Error('rxCouch.db.replace: invalid document value'); 190 | } 191 | 192 | const id = value._id; 193 | if (!id) { 194 | throw new Error('rxCouch.db.replace: _id is missing'); 195 | } 196 | 197 | if (value._rev) { 198 | throw new Error('rxCouch.db.replace: _rev is not allowed'); 199 | } 200 | 201 | return this.get(id) 202 | .catch(err => { 203 | // If no such document, create an empty placeholder document. 204 | // Otherwise, just rethrow the error. 205 | 206 | /* istanbul ignore else */ 207 | if (err.message.match(/HTTP Error 404/)) { 208 | return Rx.Observable.just({_id: id}); 209 | } else { 210 | return Rx.Observable.throw(err); 211 | } 212 | }) 213 | .flatMapLatest(oldValue => { 214 | // Apply new content as a delta against existing doc value, 215 | // but only if there is an actual change. 216 | 217 | const newValue = deepMerge(value, {_rev: oldValue._rev}); 218 | 219 | if (deepEqual(oldValue, newValue)) { 220 | return Rx.Observable.just({ 221 | id: id, 222 | ok: true, 223 | rev: oldValue._rev, 224 | noop: true 225 | }); 226 | } else { 227 | return this.put(newValue); 228 | } 229 | }); 230 | }; 231 | 232 | /** 233 | * Delete an existing document. You must pass a valid, existing document ID 234 | * and the current revision ID for that document. 235 | * 236 | * Returns an Observable which will fire exactly once on success. The result will 237 | * contain the CouchDB response object decoded from JSON. 238 | * 239 | * See http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid 240 | * for valid request options and response objects. 241 | */ 242 | 243 | db.prototype.delete = function (id, rev) { 244 | if (!id) { 245 | throw new Error('rxCouch.db.delete: missing document ID'); 246 | } 247 | 248 | if (typeof (id) !== 'string' || id.length === 0) { 249 | throw new Error('rxCouch.db.delete: invalid document ID'); 250 | } 251 | 252 | if (!rev) { 253 | throw new Error('rxCouch.db.delete: missing revision ID'); 254 | } 255 | 256 | if (typeof (rev) !== 'string' || rev.length === 0) { 257 | throw new Error('rxCouch.db.delete: invalid revision ID'); 258 | } 259 | 260 | const deleteUrl = makeDocUrl(this._dbUrl, id); 261 | 262 | const requestOptions = { 263 | method: 'delete', 264 | headers: { 265 | Accept: 'application/json', 266 | 'If-Match': rev 267 | } 268 | }; 269 | 270 | return rxFetch(deleteUrl, requestOptions).json(); 271 | }; 272 | 273 | /** 274 | * Retrieve all documents, or a specific subset of documents. 275 | * 276 | * If an options object is provided, it is converted to query options. 277 | * 278 | * Returns an Observable which will fire once on success. The result will 279 | * contain the CouchDB response object decoded from JSON. 280 | * 281 | * See http://docs.couchdb.org/en/latest/api/database/bulk-api.html#get--db-_all_docs 282 | * for valid request options and response objects. 283 | */ 284 | 285 | db.prototype.allDocs = function (options) { 286 | // Ugh. CouchDB croaks on unquoted strings for some option values. 287 | 288 | let fixedOptions = options; 289 | if (typeof (options) === 'object') { 290 | fixedOptions = {}; 291 | Object.keys(options).forEach(key => { 292 | let value = options[key]; 293 | if (typeof (value) === 'string' && !value.match(/^".*"$/)) { 294 | value = '"' + value + '"'; 295 | } 296 | fixedOptions[key] = value; 297 | }); 298 | } 299 | 300 | const getUrl = this._dbUrl + '/_all_docs' + 301 | (fixedOptions ? ('?' + querystring.stringify(fixedOptions)) : ''); 302 | 303 | return rxFetch(getUrl, {headers: {Accept: 'application/json'}}).json(); 304 | }; 305 | 306 | /** 307 | * Create or cancel a replication using this database as target. 308 | * Return an Observable which sends back the parsed JSON status 309 | * returned by CouchDB. 310 | * 311 | * See http://docs.couchdb.org/en/latest/api/server/common.html#replicate for 312 | * request and response options. Note that the URL for this database ("target") 313 | * is populated automatically. 314 | * 315 | * @param options request options 316 | * 317 | * @return Observable< Object > returns response object when available 318 | */ 319 | 320 | db.prototype.replicateFrom = function (options) { 321 | if (typeof (options) !== 'object') { 322 | throw new Error('rxCouch.db.replicateFrom: options must be an object'); 323 | } 324 | if (options.target) { 325 | throw new Error('rxCouch.db.replicateFrom: options.target must not be specified'); 326 | } 327 | 328 | let updatedOptions = shallowCopy(options); 329 | updatedOptions.target = this._dbUrl; 330 | 331 | return this._server.replicate(updatedOptions); 332 | }; 333 | 334 | /** 335 | * Monitor changes to the database. 336 | * Return an Observable with sends back the parsed JSON results 337 | * returned by CouchDB. The results are sent back one-by-one 338 | * (one for each document). 339 | * 340 | * If feed: "longpoll" appears in the options object, the changes 341 | * feed is monitored continuously until the subscription is dropped. 342 | * It is an error to use feed: "continuous". 343 | * 344 | * See http://docs.couchdb.org/en/latest/api/database/changes.html for 345 | * request and response options. 346 | * 347 | * @param options request options 348 | * 349 | * @return Observable< Object > returns response object when available 350 | */ 351 | 352 | db.prototype.changes = function (options) { 353 | if (options && typeof (options) !== 'object') { 354 | throw new Error('rxCouch.db.changes: options must be an object'); 355 | } 356 | if (options && options.feed === 'continuous') { 357 | throw new Error('rxCouch.db.changes: feed: "continuous" not supported'); 358 | } 359 | 360 | let fixedOptions = options; 361 | if (options) { 362 | fixedOptions = {}; 363 | Object.keys(options).forEach(key => { 364 | let value = options[key]; 365 | if (Array.isArray(value)) { 366 | value = '["' + value.join('","') + '"]'; 367 | } 368 | fixedOptions[key] = value; 369 | }); 370 | } 371 | 372 | const getChangesOnce = () => { 373 | const getUrl = this._dbUrl + '/_changes' + 374 | (fixedOptions ? ('?' + querystring.stringify(fixedOptions)) : ''); 375 | 376 | this._changesFetchCount++; 377 | // NOTE: This is only intended for debugging use. 378 | 379 | return rxFetch(getUrl, {headers: {Accept: 'application/json'}}) 380 | .json() 381 | .tap(response => { 382 | if (options && options.feed === 'longpoll') { 383 | fixedOptions = fixedOptions || {}; 384 | fixedOptions.since = response.last_seq; 385 | } 386 | }) 387 | .flatMap(response => Rx.Observable.from(response.results)); 388 | }; 389 | 390 | const getChangesContinuously = () => { 391 | return getChangesOnce().concat(Rx.Observable.defer(() => getChangesContinuously())); 392 | }; 393 | 394 | if (options && options.feed === 'longpoll') { 395 | return getChangesContinuously(); 396 | } else { 397 | return getChangesOnce(); 398 | } 399 | }; 400 | 401 | /** 402 | * Observe the value of an existing document over time. 403 | * 404 | * Returns an Observable which will fire once with the document's value 405 | * soon after the call. It will then monitor the document value and send 406 | * updates so long as the subscription remains active. 407 | * 408 | * Use this sparingly; having many of these open at once could lead to 409 | * unacceptable server load. 410 | * 411 | * @param id document ID 412 | * 413 | * @return Observable< Object > document value with updates as needed 414 | */ 415 | 416 | db.prototype.observe = function (id) { 417 | if (!id) { 418 | throw new Error('rxCouch.db.observe: missing document ID'); 419 | } 420 | if (typeof (id) !== 'string' || id.length === 0) { 421 | throw new Error('rxCouch.db.observe: invalid document ID'); 422 | } 423 | 424 | // Some Couch servers do not support the _doc_ids filter. If we determine 425 | // that this is such a server, then we stop trying. (We shouldn't expect the 426 | // server to change its capabilities while we're talking to it.) 427 | 428 | const self = this; 429 | 430 | const noDocIdsFilterFallback = function () { 431 | self._dbDoesNotSupportDocIdsFilter = true; 432 | if (!self._sharedChangesFeed) { 433 | self._sharedChangesFeed = self.changes({feed: 'longpoll', include_docs: true, since: 'now'}) 434 | .map(update => update.doc) 435 | .finally(() => { 436 | self._sharedChangesFeed = undefined; 437 | }) 438 | .publish().refCount(); 439 | } 440 | return self._sharedChangesFeed; 441 | }; 442 | 443 | let previousRev; 444 | 445 | return this.get(id) 446 | 447 | .catch(Rx.Observable.just({_id: id, _empty: true})) 448 | 449 | // We prefer the _doc_ids filter, when available, but if we know it isn't, 450 | // don't bother trying. 451 | 452 | .concat(this._dbDoesNotSupportDocIdsFilter 453 | ? noDocIdsFilterFallback() 454 | : (this.changes({doc_ids: [id], feed: 'longpoll', filter: '_doc_ids', include_docs: true}) 455 | .map(update => update.doc) 456 | .catch(Rx.Observable.defer(noDocIdsFilterFallback)))) 457 | 458 | .filter(doc => { 459 | if (doc._id !== id || (previousRev && previousRev === doc._rev)) { 460 | return false; 461 | } else { 462 | previousRev = doc._rev; 463 | return true; 464 | } 465 | }); 466 | }; 467 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const rxFetch = require('rx-fetch'); 4 | const url = require('url'); 5 | 6 | const Db = require('./db'); 7 | 8 | let server = module.exports = function (baseUrl) { 9 | if (!this) { 10 | throw new Error('rxCouch must be called as a constructor (i.e. `new rxCouch(baseUrl)`'); 11 | } 12 | 13 | if (!baseUrl) baseUrl = 'http://localhost:5984'; 14 | const parsedUrl = url.parse(baseUrl); 15 | const pathName = parsedUrl.pathname; 16 | if (!parsedUrl.hostname) { 17 | throw new Error('CouchDB server must not contain a path or query string'); 18 | } 19 | if (pathName && !pathName.endsWith('/')) { 20 | parsedUrl.pathname = pathName + '/'; 21 | } 22 | 23 | this._baseUrl = url.format(parsedUrl); 24 | }; 25 | 26 | const validateNameAndMakeUrl = (serverUrl, dbName, apiName) => { 27 | // Since this is an internal function, we can assume serverUrl has been 28 | // previously validated. 29 | 30 | if (typeof (dbName) !== 'string') { 31 | throw new Error('rxCouch.' + apiName + ': dbName must be a string'); 32 | } 33 | 34 | if (!dbName.match(/^[a-z][a-z0-9_$()+/-]*$/)) { 35 | throw new Error('rxCouch.' + apiName + ': illegal dbName'); 36 | } 37 | 38 | return url.resolve(serverUrl, dbName); 39 | }; 40 | 41 | /** 42 | * Return an Observable which will fire once with a list of all databases 43 | * available on the server. 44 | */ 45 | 46 | server.prototype.allDatabases = function () { 47 | return rxFetch(url.resolve(this._baseUrl, '_all_dbs')).json(); 48 | }; 49 | 50 | /** 51 | * Create a new database on the server. Return an Observable which sends only 52 | * an onCompleted event when the database has been created. 53 | * 54 | * @param dbName (String) name of database to create 55 | * @param options (optional, Object) options 56 | * - failIfExists: (optional, Boolean): if true, will fail with 412 error if 57 | * database already exists 58 | */ 59 | 60 | server.prototype.createDatabase = function (dbName, options) { 61 | if (options && typeof (options) !== 'object') { 62 | throw new Error('rxCouch.createDatabase: options, if present, must be an object'); 63 | } 64 | 65 | const failIfExists = options && options.failIfExists; 66 | if (failIfExists && typeof (failIfExists) !== 'boolean') { 67 | throw new Error('rxCouch.createDatabase: options.failIfExists, if present, must be a boolean'); 68 | } 69 | 70 | const expectedStatusValues = failIfExists ? [201] : [201, 412]; 71 | // We presume 412 means "database already exists" and quietly consume that 72 | // by default. 73 | 74 | return rxFetch(validateNameAndMakeUrl(this._baseUrl, dbName, 'createDatabase'), { method: 'put' }) 75 | .failIfStatusNotIn(expectedStatusValues) 76 | .filter(() => false); 77 | }; 78 | 79 | /** 80 | * Create a new database on the server. Return an Observable which sends only 81 | * an onCompleted event when the database has been created. 82 | */ 83 | 84 | server.prototype.deleteDatabase = function (dbName) { 85 | return rxFetch(validateNameAndMakeUrl(this._baseUrl, dbName, 'deleteDatabase'), { method: 'delete' }) 86 | .failIfStatusNotIn([200, 404]) 87 | .filter(() => false); 88 | }; 89 | 90 | /** 91 | * Create or cancel a replication. Return an Observable which sends back the 92 | * parsed JSON status returned by CouchDB. 93 | * 94 | * See http://docs.couchdb.org/en/latest/api/server/common.html#replicate for 95 | * request and response options. 96 | * 97 | * @param options request options 98 | * 99 | * @return Observable< Object > returns response object when available 100 | */ 101 | 102 | server.prototype.replicate = function (options) { 103 | if (typeof (options) !== 'object') { 104 | throw new Error('rxCouch.replicate: options must be an object'); 105 | } 106 | 107 | let requestOptions = { 108 | method: 'post', 109 | headers: { 110 | Accept: 'application/json', 111 | 'Content-Type': 'application/json' 112 | } 113 | }; 114 | 115 | requestOptions.body = JSON.stringify(options); 116 | 117 | return rxFetch(url.resolve(this._baseUrl, '_replicate'), requestOptions).json(); 118 | }; 119 | 120 | /** 121 | * Create an object that can be used to access an individual database. 122 | * Does not actually create the database on the server. 123 | */ 124 | 125 | server.prototype.db = function (dbName) { 126 | return new Db(validateNameAndMakeUrl(this._baseUrl, dbName, 'db'), this); 127 | }; 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-couch", 3 | "version": "1.7.10", 4 | "description": "RxJS-flavored APIs for CouchDB", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "semistandard && mocha", 8 | "test-travis": "semistandard && ./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha -- -R spec ./test/*" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/tangledfruit/rx-couch.git" 13 | }, 14 | "keywords": [ 15 | "couchdb", 16 | "rx", 17 | "rxjs", 18 | "nosql" 19 | ], 20 | "author": "tangledfruit ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/tangledfruit/rx-couch/issues" 24 | }, 25 | "homepage": "https://github.com/tangledfruit/rx-couch#readme", 26 | "devDependencies": { 27 | "chai": "^3.5.0", 28 | "co-mocha": "^1.1.3", 29 | "coveralls": "^2.11.13", 30 | "istanbul": "^0.4.4", 31 | "mocha": "^3.0.0", 32 | "nock": "^8.0.0", 33 | "rx-to-async-iterator": "^1.2.2", 34 | "semistandard": "^9.0.0" 35 | }, 36 | "dependencies": { 37 | "deep-eql": "^1.0.0", 38 | "deepmerge": "^1.1.0", 39 | "rx": ">=4.0.7 <5", 40 | "rx-fetch": "^1.3.4", 41 | "shallow-copy": "^0.0.1" 42 | }, 43 | "engines": { 44 | "node": ">=4", 45 | "npm": ">=3" 46 | }, 47 | "engineStrict": true, 48 | "semistandard": { 49 | "globals": [ 50 | "after", 51 | "afterEach", 52 | "before", 53 | "describe", 54 | "fetch", 55 | "it" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/testDb.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('co-mocha'); 4 | require('rx-to-async-iterator'); 5 | 6 | const Rx = require('rx'); 7 | const expect = require('chai').expect; 8 | const RxCouch = require('../lib/server'); 9 | const nock = require('nock'); 10 | const shallowCopy = require('shallow-copy'); 11 | 12 | describe('rx-couch.db()', () => { 13 | const server = new RxCouch('http://127.0.0.1:5984'); 14 | 15 | before('create test database', function * () { 16 | this.timeout(5000); 17 | 18 | const dbsAfterCreate = yield (Rx.Observable.concat( 19 | server.createDatabase('test-rx-couch-db'), 20 | server.allDatabases())).shouldGenerateOneValue(); 21 | 22 | expect(dbsAfterCreate).to.be.an('array'); 23 | expect(dbsAfterCreate).to.include('test-rx-couch-db'); 24 | }); 25 | 26 | after('remove test database', function * () { 27 | this.timeout(5000); 28 | 29 | const dbsAfterDelete = yield (Rx.Observable.concat( 30 | server.deleteDatabase('test-rx-couch-db'), 31 | server.allDatabases())).shouldGenerateOneValue(); 32 | 33 | expect(dbsAfterDelete).to.be.an('array'); 34 | expect(dbsAfterDelete).to.not.include('test-rx-couch-db'); 35 | }); 36 | 37 | it('should be defined', () => { 38 | expect(server).to.respondTo('db'); 39 | }); 40 | 41 | it('should throw if database name is missing', () => { 42 | expect(() => server.db()).to.throw('rxCouch.db: dbName must be a string'); 43 | }); 44 | 45 | it('should throw if database name is empty', () => { 46 | expect(() => server.db('')).to.throw('rxCouch.db: illegal dbName'); 47 | }); 48 | 49 | it('should throw if database name is illegal (capital letters)', () => { 50 | expect(() => server.db('noCapitalLetters')).to.throw('rxCouch.db: illegal dbName'); 51 | }); 52 | 53 | it('should throw if database name is illegal (leading underscore)', () => { 54 | expect(() => server.db('_users')).to.throw('rxCouch.db: illegal dbName'); 55 | }); 56 | 57 | const db = server.db('test-rx-couch-db'); 58 | // Defined out of scope because we use it throughout this test suite. 59 | 60 | it('should return an object', () => { 61 | expect(db).to.be.an('object'); 62 | }); 63 | 64 | let randomDocId; 65 | let rev1, rev2; 66 | 67 | describe('.put()', () => { 68 | it('should be defined', () => { 69 | expect(db).to.respondTo('put'); 70 | }); 71 | 72 | it('should throw if no document value is provided', () => { 73 | expect(() => db.put()).to.throw('rxCouch.db.put: missing document value'); 74 | }); 75 | 76 | it('should throw if an invalid document value is provided', () => { 77 | expect(() => db.put(42)).to.throw('rxCouch.db.put: invalid document value'); 78 | }); 79 | 80 | it('should assign a document ID if no document ID is provided', function * () { 81 | // http://docs.couchdb.org/en/latest/api/database/common.html#post--db 82 | 83 | const putResponse = yield db.put({foo: 'bar'}).shouldGenerateOneValue(); 84 | 85 | expect(putResponse).to.be.an('object'); 86 | expect(putResponse.id).to.be.a('string'); 87 | expect(putResponse.ok).to.equal(true); 88 | expect(putResponse.rev).to.be.a('string'); 89 | 90 | randomDocId = putResponse.id; 91 | }); 92 | 93 | it('should create a new document using specific ID if provided', function * () { 94 | // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 95 | 96 | const putResponse = yield db.put({_id: 'testing123', foo: 'bar'}).shouldGenerateOneValue(); 97 | 98 | expect(putResponse).to.be.an('object'); 99 | expect(putResponse.id).to.equal('testing123'); 100 | expect(putResponse.ok).to.equal(true); 101 | expect(putResponse.rev).to.match(/^1-/); 102 | rev1 = putResponse.rev; 103 | }); 104 | 105 | it('should not alter the object that was provided to it', function * () { 106 | // http://docs.couchdb.org/en/latest/api/document/common.html#put--db-docid 107 | 108 | let putObject = {_id: 'testing234', foo: 'bar'}; 109 | const putResponse = yield db.put(putObject).shouldGenerateOneValue(); 110 | 111 | expect(putResponse).to.be.an('object'); 112 | expect(putResponse.id).to.equal('testing234'); 113 | expect(putObject).to.deep.equal({_id: 'testing234', foo: 'bar'}); 114 | }); 115 | 116 | it('should update an existing document when _id and _rev are provided', function * () { 117 | const putResponse = yield db.put({_id: 'testing123', _rev: rev1, foo: 'baz'}).shouldGenerateOneValue(); 118 | 119 | expect(putResponse).to.be.an('object'); 120 | expect(putResponse.id).to.equal('testing123'); 121 | expect(putResponse.ok).to.equal(true); 122 | expect(putResponse.rev).to.be.match(/^2-/); 123 | rev2 = putResponse.rev; 124 | }); 125 | 126 | it('should fail when _id matches an existing document but no _rev is provided', function * () { 127 | const err = yield db.put({_id: 'testing123', foo: 'bar'}).shouldThrow(); 128 | expect(err.message).to.equal('HTTP Error 409 on http://127.0.0.1:5984/test-rx-couch-db/testing123: Conflict'); 129 | }); 130 | 131 | it('should fail when _id matches an existing document but incorrect _rev is provided', function * () { 132 | const err = yield db.put({_id: 'testing123', '_rev': 'bogus', foo: 'bar'}).shouldThrow(); 133 | expect(err.message).to.equal('HTTP Error 400 on http://127.0.0.1:5984/test-rx-couch-db/testing123: Bad Request'); 134 | }); 135 | }); 136 | 137 | describe('.get()', () => { 138 | // http://docs.couchdb.org/en/latest/api/document/common.html#get--db-docid 139 | 140 | it('should be defined', () => { 141 | expect(db).to.respondTo('get'); 142 | }); 143 | 144 | it('should throw if no document ID is provided', () => { 145 | expect(() => db.get()).to.throw('rxCouch.db.get: missing document ID'); 146 | }); 147 | 148 | it('should throw if an invalid document ID is provided', () => { 149 | expect(() => db.get(42)).to.throw('rxCouch.db.get: invalid document ID'); 150 | }); 151 | 152 | it("should retrieve a document's current value if no options are provided", function * () { 153 | const getResponse = yield db.get('testing123').shouldGenerateOneValue(); 154 | expect(getResponse).to.be.an('object'); 155 | expect(getResponse._id).to.equal('testing123'); 156 | expect(getResponse._rev).to.match(/^2-/); 157 | expect(getResponse.foo).to.equal('baz'); 158 | }); 159 | 160 | it('should pass through options when provided', function * () { 161 | const getResponse = yield db.get('testing123', {rev: rev1}).shouldGenerateOneValue(); 162 | expect(getResponse).to.be.an('object'); 163 | expect(getResponse._id).to.equal('testing123'); 164 | expect(getResponse._rev).to.match(/^1-/); 165 | expect(getResponse.foo).to.equal('bar'); 166 | }); 167 | 168 | it("should fail when _id doesn't match an existing document", function * () { 169 | const err = yield db.get('testing432').shouldThrow(); 170 | expect(err.message).to.equal('HTTP Error 404 on http://127.0.0.1:5984/test-rx-couch-db/testing432: Object Not Found'); 171 | }); 172 | }); 173 | 174 | describe('.update()', () => { 175 | it('should be defined', () => { 176 | expect(db).to.respondTo('update'); 177 | }); 178 | 179 | it('should throw if no document value is provided', () => { 180 | expect(() => db.update()).to.throw('rxCouch.db.update: missing document value'); 181 | }); 182 | 183 | it('should throw if an invalid document value is provided', () => { 184 | expect(() => db.update(42)).to.throw('rxCouch.db.update: invalid document value'); 185 | }); 186 | 187 | it('should throw if no document ID is provided', () => { 188 | expect(() => db.update({})).to.throw('rxCouch.db.update: _id is missing'); 189 | }); 190 | 191 | it('should throw if a revision ID is provided', () => { 192 | expect(() => db.update({_id: 'blah', _rev: '42-bogus'})) 193 | .to.throw('rxCouch.db.update: _rev is not allowed'); 194 | }); 195 | 196 | let initialRev; 197 | 198 | it('should create a new document if no existing document exists', function * () { 199 | const updateResponse = yield db.update({_id: 'update-test', foo: 'bar'}).shouldGenerateOneValue(); 200 | expect(updateResponse).to.be.an('object'); 201 | expect(updateResponse.id).to.equal('update-test'); 202 | expect(updateResponse.ok).to.equal(true); 203 | expect(updateResponse.rev).to.match(/^1-/); 204 | initialRev = updateResponse.rev; 205 | 206 | const getResponse = yield db.get('update-test').shouldGenerateOneValue(); 207 | expect(getResponse).to.deep.equal({ 208 | _id: 'update-test', 209 | _rev: initialRev, 210 | foo: 'bar' 211 | }); 212 | }); 213 | 214 | it('should not alter the object that was provided to it', function * () { 215 | let updateObject = {_id: 'update-test-2', foo: 'bar'}; 216 | const updateResponse = yield db.update(updateObject).shouldGenerateOneValue(); 217 | 218 | expect(updateResponse).to.be.an('object'); 219 | expect(updateResponse.id).to.equal('update-test-2'); 220 | expect(updateObject).to.deep.equal({_id: 'update-test-2', foo: 'bar'}); 221 | }); 222 | 223 | it('should not create a new revision if nothing changed', function * () { 224 | const updateResponse = yield db.update({_id: 'update-test', foo: 'bar'}).shouldGenerateOneValue(); 225 | 226 | expect(updateResponse).to.deep.equal({ 227 | id: 'update-test', 228 | ok: true, 229 | rev: initialRev, 230 | noop: true 231 | }); 232 | 233 | const value = yield db.get('update-test').shouldGenerateOneValue(); 234 | expect(value).to.deep.equal({ 235 | _id: 'update-test', 236 | _rev: initialRev, 237 | foo: 'bar' 238 | }); 239 | }); 240 | 241 | it('should update an existing document when new content is provided', function * () { 242 | const updateResponse = yield db.update({_id: 'update-test', foo: 'baz'}).shouldGenerateOneValue(); 243 | 244 | expect(updateResponse).to.be.an('object'); 245 | expect(updateResponse.id).to.equal('update-test'); 246 | expect(updateResponse.ok).to.equal(true); 247 | expect(updateResponse.rev).to.be.match(/^2-/); 248 | let rev2 = updateResponse.rev; 249 | 250 | const value = yield db.get('update-test').shouldGenerateOneValue(); 251 | expect(value).to.deep.equal({ 252 | _id: 'update-test', 253 | _rev: rev2, 254 | foo: 'baz' 255 | }); 256 | }); 257 | }); 258 | 259 | describe('.replace()', () => { 260 | it('should be defined', () => { 261 | expect(db).to.respondTo('replace'); 262 | }); 263 | 264 | it('should throw if no document value is provided', () => { 265 | expect(() => db.replace()).to.throw('rxCouch.db.replace: missing document value'); 266 | }); 267 | 268 | it('should throw if an invalid document value is provided', () => { 269 | expect(() => db.replace(42)).to.throw('rxCouch.db.replace: invalid document value'); 270 | }); 271 | 272 | it('should throw if no document ID is provided', () => { 273 | expect(() => db.replace({})).to.throw('rxCouch.db.replace: _id is missing'); 274 | }); 275 | 276 | it('should throw if a revision ID is provided', () => { 277 | expect(() => db.replace({_id: 'blah', _rev: '42-bogus'})) 278 | .to.throw('rxCouch.db.replace: _rev is not allowed'); 279 | }); 280 | 281 | let initialRev; 282 | 283 | it('should create a new document if no existing document exists', function * () { 284 | const replaceResponse = yield db.replace({_id: 'replace-test', foo: 'bar'}).shouldGenerateOneValue(); 285 | 286 | expect(replaceResponse).to.be.an('object'); 287 | expect(replaceResponse.id).to.equal('replace-test'); 288 | expect(replaceResponse.ok).to.equal(true); 289 | expect(replaceResponse.rev).to.match(/^1-/); 290 | initialRev = replaceResponse.rev; 291 | 292 | const getResponse = yield db.get('replace-test').shouldGenerateOneValue(); 293 | expect(getResponse).to.deep.equal({ 294 | _id: 'replace-test', 295 | _rev: initialRev, 296 | foo: 'bar' 297 | }); 298 | }); 299 | 300 | it('should not alter the object that was provided to it', function * () { 301 | let replaceObject = {_id: 'replace-test-2', foo: 'bar'}; 302 | const replaceResponse = yield db.replace(replaceObject).shouldGenerateOneValue(); 303 | 304 | expect(replaceResponse).to.be.an('object'); 305 | expect(replaceResponse.id).to.equal('replace-test-2'); 306 | expect(replaceObject).to.deep.equal({_id: 'replace-test-2', foo: 'bar'}); 307 | }); 308 | 309 | it('should not create a new revision if nothing changed', function * () { 310 | const replaceResponse = yield db.replace({_id: 'replace-test', foo: 'bar'}).shouldGenerateOneValue(); 311 | 312 | expect(replaceResponse).to.deep.equal({ 313 | id: 'replace-test', 314 | ok: true, 315 | rev: initialRev, 316 | noop: true 317 | }); 318 | 319 | const value = yield db.get('replace-test').shouldGenerateOneValue(); 320 | expect(value).to.deep.equal({ 321 | _id: 'replace-test', 322 | _rev: initialRev, 323 | foo: 'bar' 324 | }); 325 | }); 326 | 327 | it('should replace an existing document when new content is provided', function * () { 328 | const replaceResponse = yield db.replace({_id: 'replace-test', flip: 'baz'}).shouldGenerateOneValue(); 329 | 330 | expect(replaceResponse).to.be.an('object'); 331 | expect(replaceResponse.id).to.equal('replace-test'); 332 | expect(replaceResponse.ok).to.equal(true); 333 | expect(replaceResponse.rev).to.be.match(/^2-/); 334 | let rev2 = replaceResponse.rev; 335 | 336 | const value = yield db.get('replace-test').shouldGenerateOneValue(); 337 | expect(value).to.deep.equal({ 338 | _id: 'replace-test', 339 | _rev: rev2, 340 | flip: 'baz' 341 | // foo should be removed 342 | }); 343 | }); 344 | }); 345 | 346 | describe('.allDocs()', () => { 347 | it('should be defined', () => { 348 | expect(db).to.respondTo('delete'); 349 | }); 350 | 351 | it('should return summary information about all documents with no query options', function * () { 352 | const allDocsResult = yield db.allDocs().shouldGenerateOneValue(); 353 | const simplifiedDocsResult = shallowCopy(allDocsResult); 354 | simplifiedDocsResult.rows = allDocsResult.rows.map(row => { 355 | if (typeof (row) !== 'object') { 356 | return row; 357 | } else { 358 | let rowCopy = shallowCopy(row); 359 | if (typeof (row.value) === 'object' && typeof (row.value.rev) === 'string') { 360 | rowCopy.value.rev = 'rev'; 361 | } 362 | return rowCopy; 363 | } 364 | }); 365 | 366 | expect(simplifiedDocsResult).to.deep.equal({ 367 | offset: 0, 368 | rows: [ 369 | { 370 | id: randomDocId, 371 | key: randomDocId, 372 | value: { 373 | rev: 'rev' 374 | } 375 | }, 376 | { 377 | id: 'replace-test', 378 | key: 'replace-test', 379 | value: { 380 | rev: 'rev' 381 | } 382 | }, 383 | { 384 | id: 'replace-test-2', 385 | key: 'replace-test-2', 386 | value: { 387 | rev: 'rev' 388 | } 389 | }, 390 | { 391 | id: 'testing123', 392 | key: 'testing123', 393 | value: { 394 | rev: 'rev' 395 | } 396 | }, 397 | { 398 | id: 'testing234', 399 | key: 'testing234', 400 | value: { 401 | rev: 'rev' 402 | } 403 | }, 404 | { 405 | id: 'update-test', 406 | key: 'update-test', 407 | value: { 408 | rev: 'rev' 409 | } 410 | }, 411 | { 412 | id: 'update-test-2', 413 | key: 'update-test-2', 414 | value: { 415 | rev: 'rev' 416 | } 417 | } 418 | ], 419 | total_rows: 7 420 | }); 421 | }); 422 | 423 | it('should return full document values for some documents with appropriate query parameters', function * () { 424 | const allDocsResult = yield db.allDocs({ 425 | startkey: 'testing123', 426 | endkey: 'testing234', 427 | include_docs: true 428 | }).shouldGenerateOneValue(); 429 | 430 | const simplifiedDocsResult = shallowCopy(allDocsResult); 431 | simplifiedDocsResult.rows = allDocsResult.rows.map(row => { 432 | if (typeof (row) !== 'object') { 433 | return row; 434 | } else { 435 | let rowCopy = shallowCopy(row); 436 | if (typeof (row.doc) === 'object' && typeof (row.doc._rev) === 'string') { 437 | rowCopy.doc._rev = 'rev'; 438 | } 439 | if (typeof (row.value) === 'object' && typeof (row.value.rev) === 'string') { 440 | rowCopy.value.rev = 'rev'; 441 | } 442 | return rowCopy; 443 | } 444 | }); 445 | 446 | expect(simplifiedDocsResult).to.deep.equal({ 447 | offset: 3, 448 | rows: [ 449 | { 450 | doc: { 451 | _id: 'testing123', 452 | _rev: 'rev', 453 | foo: 'baz' 454 | }, 455 | id: 'testing123', 456 | key: 'testing123', 457 | value: { 458 | rev: 'rev' 459 | } 460 | }, 461 | { 462 | doc: { 463 | _id: 'testing234', 464 | _rev: 'rev', 465 | foo: 'bar' 466 | }, 467 | id: 'testing234', 468 | key: 'testing234', 469 | value: { 470 | rev: 'rev' 471 | } 472 | } 473 | ], 474 | total_rows: 7 475 | }); 476 | }); 477 | }); 478 | 479 | describe('.delete()', () => { 480 | it('should be defined', () => { 481 | expect(db).to.respondTo('delete'); 482 | }); 483 | 484 | it('should throw if no document ID is provided', () => { 485 | expect(() => db.delete()).to.throw('rxCouch.db.delete: missing document ID'); 486 | }); 487 | 488 | it('should throw if an invalid document ID is provided', () => { 489 | expect(() => db.delete(42)).to.throw('rxCouch.db.delete: invalid document ID'); 490 | }); 491 | 492 | it('should throw if no revision ID is provided', () => { 493 | expect(() => db.delete('testing123')).to.throw('rxCouch.db.delete: missing revision ID'); 494 | }); 495 | 496 | it('should throw if an invalid revision ID is provided', () => { 497 | expect(() => db.delete('testing123', 42)).to.throw('rxCouch.db.delete: invalid revision ID'); 498 | }); 499 | 500 | it('should fail when _id matches an existing document but incorrect _rev is provided', function * () { 501 | const err = yield db.delete('testing123', 'bogus').shouldThrow(); 502 | expect(err.message).to.equal('HTTP Error 400 on http://127.0.0.1:5984/test-rx-couch-db/testing123: Bad Request'); 503 | }); 504 | 505 | it('should delete an existing document when correct _id and _rev are provided', function * () { 506 | const deleteResponse = yield db.delete('testing123', rev2).shouldGenerateOneValue(); 507 | expect(deleteResponse).to.be.an('object'); 508 | expect(deleteResponse.id).to.equal('testing123'); 509 | expect(deleteResponse.ok).to.equal(true); 510 | expect(deleteResponse.rev).to.match(/^3-/); 511 | }); 512 | 513 | it('should actually have deleted the existing document', function * () { 514 | const err = yield db.get('testing123').shouldThrow(); 515 | expect(err.message).to.equal('HTTP Error 404 on http://127.0.0.1:5984/test-rx-couch-db/testing123: Object Not Found'); 516 | }); 517 | }); 518 | 519 | describe('.replicateFrom()', () => { 520 | const srcDb = server.db('test-rx-couch-clone-source'); 521 | 522 | before('create test databases', function * () { 523 | yield server.createDatabase('test-rx-couch-clone-source').shouldBeEmpty(); 524 | yield server.createDatabase('test-rx-couch-clone-target').shouldBeEmpty(); 525 | let putObject = {_id: 'testing234', foo: 'bar'}; 526 | yield srcDb.put(putObject).shouldGenerateOneValue(); 527 | }); 528 | 529 | after('destroy test databases', function * () { 530 | yield server.deleteDatabase('test-rx-couch-clone-source').shouldBeEmpty(); 531 | yield server.deleteDatabase('test-rx-couch-clone-target').shouldBeEmpty(); 532 | }); 533 | 534 | it('should throw if options is missing', () => { 535 | expect(() => srcDb.replicateFrom()).to.throw('rxCouch.db.replicateFrom: options must be an object'); 536 | }); 537 | 538 | it('should throw if options is not an object', () => { 539 | expect(() => srcDb.replicateFrom('blah')).to.throw('rxCouch.db.replicateFrom: options must be an object'); 540 | }); 541 | 542 | it('should throw if options contains a "target" entry', () => { 543 | expect(() => srcDb.replicateFrom({ 544 | source: 'test-rx-couch-clone-source', 545 | target: 'test-rx-couch-clone-target' 546 | })).to.throw('rxCouch.db.replicateFrom: options.target must not be specified'); 547 | }); 548 | 549 | it('should return an Observable with status information', function * () { 550 | const replResult = yield srcDb.replicateFrom({ 551 | source: 'test-rx-couch-clone-source' 552 | }).shouldGenerateOneValue(); 553 | 554 | expect(replResult).to.be.an('object'); 555 | expect(replResult.ok).to.equal(true); 556 | expect(replResult.history).to.be.an('array'); 557 | }); 558 | }); 559 | 560 | describe('.changes()', () => { 561 | it('should throw if options is not an object', () => { 562 | expect(() => db.changes('blah')).to.throw('rxCouch.db.changes: options must be an object'); 563 | }); 564 | 565 | it('should throw if options.feed === "continuous"', () => { 566 | expect(() => db.changes({feed: 'continuous'})).to.throw('rxCouch.db.changes: feed: "continuous" not supported'); 567 | }); 568 | 569 | it('should return summary information about all documents with no query options', function * () { 570 | const iter = db.changes() 571 | .skip(1) 572 | .map(result => { 573 | result.changes = 'changes suppressed'; 574 | return result; 575 | }) 576 | .toAsyncIterator(); 577 | // Previous tests add one document with random ID. 578 | // Skip that since it will be hard to match. 579 | 580 | expect(yield iter.nextValue()).to.deep.equal({ 581 | changes: 'changes suppressed', 582 | id: 'testing234', 583 | seq: 3 584 | }); 585 | 586 | expect(yield iter.nextValue()).to.deep.equal({ 587 | changes: 'changes suppressed', 588 | id: 'update-test-2', 589 | seq: 6 590 | }); 591 | 592 | expect(yield iter.nextValue()).to.deep.equal({ 593 | changes: 'changes suppressed', 594 | id: 'update-test', 595 | seq: 7 596 | }); 597 | 598 | expect(yield iter.nextValue()).to.deep.equal({ 599 | changes: 'changes suppressed', 600 | id: 'replace-test-2', 601 | seq: 9 602 | }); 603 | 604 | expect(yield iter.nextValue()).to.deep.equal({ 605 | changes: 'changes suppressed', 606 | id: 'replace-test', 607 | seq: 10 608 | }); 609 | 610 | expect(yield iter.nextValue()).to.deep.equal({ 611 | changes: 'changes suppressed', 612 | deleted: true, 613 | id: 'testing123', 614 | seq: 11 615 | }); 616 | 617 | yield iter.shouldComplete(); 618 | }); 619 | 620 | it('should return full document values for some documents with appropriate query parameters', function * () { 621 | const iter = db.changes({ 622 | doc_ids: ['testing123', 'testing234'], 623 | filter: '_doc_ids', 624 | include_docs: true 625 | }) 626 | .map(result => { 627 | result.changes = 'changes suppressed'; 628 | result.doc._rev = 'rev ID suppressed'; 629 | return result; 630 | }) 631 | .toAsyncIterator(); 632 | 633 | expect(yield iter.nextValue()).to.deep.equal({ 634 | changes: 'changes suppressed', 635 | doc: { 636 | _id: 'testing234', 637 | _rev: 'rev ID suppressed', 638 | foo: 'bar' 639 | }, 640 | id: 'testing234', 641 | seq: 3 642 | }); 643 | 644 | expect(yield iter.nextValue()).to.deep.equal({ 645 | changes: 'changes suppressed', 646 | deleted: true, 647 | doc: { 648 | _deleted: true, 649 | _id: 'testing123', 650 | _rev: 'rev ID suppressed' 651 | }, 652 | id: 'testing123', 653 | seq: 11 654 | }); 655 | 656 | yield iter.shouldComplete(); 657 | }); 658 | 659 | it('should continuously monitor the database if feed: longpoll is used', function * () { 660 | const iter = db.changes({ 661 | doc_ids: ['testing234'], 662 | feed: 'longpoll', 663 | filter: '_doc_ids', 664 | include_docs: true 665 | }) 666 | .map(result => { 667 | result.changes = 'changes suppressed'; 668 | result.doc._rev = 'rev ID suppressed'; 669 | return result; 670 | }) 671 | .toAsyncIterator(); 672 | 673 | expect(yield iter.nextValue()).to.deep.equal({ 674 | changes: 'changes suppressed', 675 | doc: { 676 | _id: 'testing234', 677 | _rev: 'rev ID suppressed', 678 | foo: 'bar' 679 | }, 680 | id: 'testing234', 681 | seq: 3 682 | }); 683 | 684 | yield db.update({_id: 'testing234', foo: 'blah'}).shouldGenerateOneValue(); 685 | // Ignore result: Assume other tests have verified behavior of update method. 686 | 687 | expect(yield iter.nextValue()).to.deep.equal({ 688 | changes: 'changes suppressed', 689 | doc: { 690 | _id: 'testing234', 691 | _rev: 'rev ID suppressed', 692 | foo: 'blah' 693 | }, 694 | id: 'testing234', 695 | seq: 12 696 | }); 697 | 698 | yield db.update({_id: 'testing123', count: 38}).shouldGenerateOneValue(); 699 | // Should generate no updates: We're not watching this document. 700 | 701 | yield db.update({_id: 'testing234', bop: 'blip'}).shouldGenerateOneValue(); 702 | 703 | expect(yield iter.nextValue()).to.deep.equal({ 704 | changes: 'changes suppressed', 705 | doc: { 706 | _id: 'testing234', 707 | _rev: 'rev ID suppressed', 708 | bop: 'blip', 709 | foo: 'blah' 710 | }, 711 | id: 'testing234', 712 | seq: 14 713 | }); 714 | 715 | iter.unsubscribe(); 716 | }); 717 | 718 | it('should stop monitoring the database when unsubscribed', function * () { 719 | const iter = db.changes({ 720 | doc_ids: ['testing234'], 721 | feed: 'longpoll', 722 | filter: '_doc_ids', 723 | include_docs: true, 724 | timeout: 100 725 | }) 726 | .map(result => { 727 | result.changes = 'changes suppressed'; 728 | result.doc._rev = 'rev ID suppressed'; 729 | return result; 730 | }) 731 | .toAsyncIterator(); 732 | 733 | expect(yield iter.nextValue()).to.deep.equal({ 734 | changes: 'changes suppressed', 735 | doc: { 736 | _id: 'testing234', 737 | _rev: 'rev ID suppressed', 738 | bop: 'blip', 739 | foo: 'blah' 740 | }, 741 | id: 'testing234', 742 | seq: 14 743 | }); 744 | 745 | const fetchCountAtUnsubscribe = db._changesFetchCount; 746 | // Yes, this is hacky groping of the db object's internals. 747 | // Do not count on this member variable remaining present. 748 | 749 | iter.unsubscribe(); 750 | 751 | yield Rx.Observable.timer(400).shouldGenerateOneValue(); 752 | // Sleep through at least one (no-op) fetch cycle. 753 | 754 | expect(db._changesFetchCount).to.equal(fetchCountAtUnsubscribe); 755 | }); 756 | 757 | it('should continue to monitor changes even if a timeout occurs', function * () { 758 | const iter = db.changes({ 759 | doc_ids: ['testing234'], 760 | feed: 'longpoll', 761 | filter: '_doc_ids', 762 | include_docs: true, 763 | timeout: 500 764 | }) 765 | .map(result => { 766 | result.changes = 'changes suppressed'; 767 | result.doc._rev = 'rev ID suppressed'; 768 | return result; 769 | }) 770 | .toAsyncIterator(); 771 | 772 | expect(yield iter.nextValue()).to.deep.equal({ 773 | changes: 'changes suppressed', 774 | doc: { 775 | _id: 'testing234', 776 | _rev: 'rev ID suppressed', 777 | bop: 'blip', 778 | foo: 'blah' 779 | }, 780 | id: 'testing234', 781 | seq: 14 782 | }); 783 | 784 | yield Rx.Observable.timer(750).shouldGenerateOneValue(); 785 | // Sleep past the 500ms timeout specified above. 786 | 787 | yield db.update({_id: 'testing234', foo: 'blam'}).shouldGenerateOneValue(); 788 | // Ignore result: Assume other tests have verified behavior of update method. 789 | 790 | expect(yield iter.nextValue()).to.deep.equal({ 791 | changes: 'changes suppressed', 792 | doc: { 793 | _id: 'testing234', 794 | _rev: 'rev ID suppressed', 795 | bop: 'blip', 796 | foo: 'blam' 797 | }, 798 | id: 'testing234', 799 | seq: 15 800 | }); 801 | 802 | iter.unsubscribe(); 803 | }); 804 | }); 805 | 806 | describe('.observe()', () => { 807 | afterEach(() => { 808 | nock.cleanAll(); 809 | }); 810 | 811 | it('should throw if id is missing', () => { 812 | expect(() => db.observe()).to.throw('rxCouch.db.observe: missing document ID'); 813 | }); 814 | 815 | it('should throw if id is not a string', () => { 816 | expect(() => db.observe(42)).to.throw('rxCouch.db.observe: invalid document ID'); 817 | }); 818 | 819 | it('should send the current document value immediately if it exists, then update it with new value when it exists', function * () { 820 | const iter = db.observe('testing234') 821 | .map(doc => { delete doc._rev; return doc; }) 822 | .toAsyncIterator(); 823 | 824 | expect(yield iter.nextValue()).to.deep.equal({ 825 | _id: 'testing234', 826 | bop: 'blip', 827 | foo: 'blam' 828 | }); 829 | 830 | yield db.update({_id: 'testing234', phone: 'ring'}).shouldGenerateOneValue(); 831 | 832 | expect(yield iter.nextValue()).to.deep.equal({ 833 | _id: 'testing234', 834 | bop: 'blip', 835 | foo: 'blam', 836 | phone: 'ring' 837 | }); 838 | 839 | iter.unsubscribe(); 840 | }); 841 | 842 | it('should return a placeholder if the document does not exist, then replace it with the correct value when it does exist', function * () { 843 | const iter = db.observe('testing987') 844 | .map(doc => { delete doc._rev; return doc; }) 845 | .toAsyncIterator(); 846 | 847 | expect(yield iter.nextValue()).to.deep.equal({ 848 | _id: 'testing987', 849 | _empty: true 850 | }); 851 | 852 | yield db.update({_id: 'testing987', phone: 'ring'}).shouldGenerateOneValue(); 853 | 854 | expect(yield iter.nextValue()).to.deep.equal({ 855 | _id: 'testing987', 856 | phone: 'ring' 857 | }); 858 | 859 | iter.unsubscribe(); 860 | }); 861 | 862 | it("should work even if the server doesn't support _doc_ids filter", function * () { 863 | // For example: Couchbase Lite on iOS ... 864 | const server = new RxCouch('http://localhost:5979'); 865 | const db = server.db('test-rx-couch-db'); 866 | 867 | nock('http://localhost:5979') 868 | .get('/test-rx-couch-db/testing987') 869 | .reply(200, '{"_id":"testing987","_rev":"1-9b37e2fd94778a46692565e0563a0a4f","phone":"ring"}'); 870 | 871 | nock('http://localhost:5979') 872 | .get('/test-rx-couch-db/_changes?doc_ids=%5B%22testing987%22%5D&feed=longpoll&filter=_doc_ids&include_docs=true') 873 | .reply(404, '{"error":"not_found","reason":"missing"}'); // CBL's way of saying not supported 874 | 875 | nock('http://localhost:5979') 876 | .get('/test-rx-couch-db/_changes?feed=longpoll&include_docs=true&since=now') 877 | .delay(200) 878 | .reply(200, '{"results":[{"seq":17,"id":"testing987","changes":[{"rev":"1-9b37e2fd94778a46692565e0563a0a4f"}],"doc":{"_id":"testing987","_rev":"1-9b37e2fd94778a46692565e0563a0a4f","phone":"ring"}}],"last_seq":17}'); 879 | 880 | nock('http://localhost:5979') 881 | .get('/test-rx-couch-db/testing987') 882 | .reply(200, '{"_id":"testing987","_rev":"1-9b37e2fd94778a46692565e0563a0a4f","phone":"ring"}'); 883 | 884 | nock('http://localhost:5979') 885 | .put('/test-rx-couch-db/testing987', '{"phone":"hup"}') 886 | .reply(201, '{"ok":true,"id":"testing987","rev":"2-35a5f4b576f2b82f80bc69e71178d236"}'); 887 | 888 | nock('http://localhost:5979') 889 | .get('/test-rx-couch-db/_changes?feed=longpoll&include_docs=true&since=17') 890 | .reply(200, '{"results":[{"seq":18,"id":"testing987","changes":[{"rev":"2-35a5f4b576f2b82f80bc69e71178d236"}],"doc":{"_id":"testing987","_rev":"2-35a5f4b576f2b82f80bc69e71178d236","phone":"hup"}}],"last_seq":18}'); 891 | 892 | nock('http://localhost:5979') 893 | .get('/test-rx-couch-db/_changes?feed=longpoll&include_docs=true&since=18') 894 | .delay(1000) 895 | .reply(200, '{"results":[],"last_seq":18}'); 896 | 897 | nock('http://localhost:5979') 898 | .get('/test-rx-couch-db/testing987') 899 | .reply(200, '{"_id":"testing987","_rev":"2-35a5f4b576f2b82f80bc69e71178d236","phone":"hup"}'); 900 | 901 | nock('http://localhost:5979') 902 | .get('/test-rx-couch-db/testing987') 903 | .reply(200, '{"_id":"testing987","_rev":"2-35a5f4b576f2b82f80bc69e71178d236","phone":"hup"}'); 904 | 905 | nock('http://localhost:5979') 906 | .put('/test-rx-couch-db/testing987', '{"phone":"again?"}') 907 | .reply(201, '{"ok":true,"id":"testing987","rev":"3-mumble"}'); 908 | 909 | nock('http://localhost:5979') 910 | .get('/test-rx-couch-db/_changes?feed=longpoll&include_docs=true&since=now') 911 | .delay(200) 912 | .reply(200, '{"results":[{"seq":19,"id":"testing987","changes":[{"rev":"3-mumble"}],"doc":{"_id":"testing987","_rev":"3-mumble","phone":"again?"}}],"last_seq":19}'); 913 | 914 | const iter = db.observe('testing987') 915 | .map(doc => { delete doc._rev; return doc; }) 916 | .toAsyncIterator(); 917 | 918 | expect(yield iter.nextValue()).to.deep.equal({ 919 | _id: 'testing987', 920 | phone: 'ring' 921 | }); 922 | 923 | yield db.update({_id: 'testing987', phone: 'hup'}).shouldGenerateOneValue(); 924 | 925 | expect(yield iter.nextValue()).to.deep.equal({ 926 | _id: 'testing987', 927 | phone: 'hup' 928 | }); 929 | 930 | expect(db._sharedChangesFeed).to.not.equal(undefined); 931 | // Hacky: Sniffing the implementation details. 932 | 933 | iter.unsubscribe(); 934 | 935 | // Make sure we can start observing again after all previous 936 | // subscriptions have ended. 937 | 938 | expect(db._sharedChangesFeed).to.equal(undefined); 939 | // Hacky: Sniffing the implementation details. 940 | 941 | const iter2 = db.observe('testing987') 942 | .map(doc => { delete doc._rev; return doc; }) 943 | .toAsyncIterator(); 944 | 945 | expect(yield iter2.nextValue()).to.deep.equal({ 946 | _id: 'testing987', 947 | phone: 'hup' 948 | }); 949 | 950 | yield db.update({_id: 'testing987', phone: 'again?'}).shouldGenerateOneValue(); 951 | 952 | expect(yield iter2.nextValue()).to.deep.equal({ 953 | _id: 'testing987', 954 | phone: 'again?' 955 | }); 956 | 957 | iter2.unsubscribe(); 958 | }); 959 | 960 | it('should return a placeholder if the document is deleted', function * () { 961 | let revId; 962 | 963 | const iter = db.observe('testing987') 964 | .map(doc => { revId = doc._rev; delete doc._rev; return doc; }) 965 | .toAsyncIterator(); 966 | 967 | expect(yield iter.nextValue()).to.deep.equal({ 968 | _id: 'testing987', 969 | phone: 'ring' 970 | }); 971 | 972 | yield db.delete('testing987', revId).shouldGenerateOneValue(); 973 | 974 | expect(yield iter.nextValue()).to.deep.equal({ 975 | _id: 'testing987', 976 | _deleted: true 977 | }); 978 | 979 | yield db.update({_id: 'testing987', phone: 'again?'}).shouldGenerateOneValue(); 980 | 981 | expect(yield iter.nextValue()).to.deep.equal({ 982 | _id: 'testing987', 983 | phone: 'again?' 984 | }); 985 | }); 986 | }); 987 | }); 988 | -------------------------------------------------------------------------------- /test/testServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('co-mocha'); 4 | require('rx-to-async-iterator'); 5 | 6 | const Rx = require('rx'); 7 | const expect = require('chai').expect; 8 | const nock = require('nock'); 9 | const RxCouch = require('../lib/server'); 10 | 11 | describe('rx-couch', () => { 12 | it('should be defined', () => { 13 | expect(RxCouch).to.be.a('function'); 14 | }); 15 | 16 | it('should fail if called as a non-constructor', () => { 17 | expect(() => RxCouch('http://localhost:5984')).to.throw(/new rxCouch/); 18 | // WRONG: should be `new rxCouch(...)`! 19 | }); 20 | 21 | it('should fail for malformed URL', () => { 22 | expect(() => new RxCouch('not a valid URL')) 23 | .to.throw(/CouchDB server must not contain a path or query string/); 24 | }); 25 | 26 | it('should allow the base URL for the database server to contain a path', () => { 27 | const server = new RxCouch('http://localhost:5984/couch/'); 28 | expect(server._baseUrl).to.equal('http://localhost:5984/couch/'); 29 | const db = server.db('some_db'); 30 | expect(db._dbUrl).to.equal('http://localhost:5984/couch/some_db'); 31 | }); 32 | 33 | it('should allow the base URL for the database server to contain a path without a trailing slash', () => { 34 | const server = new RxCouch('http://localhost:5984/couch'); 35 | expect(server._baseUrl).to.equal('http://localhost:5984/couch/'); 36 | const db = server.db('some_db'); 37 | expect(db._dbUrl).to.equal('http://localhost:5984/couch/some_db'); 38 | }); 39 | 40 | const server = new RxCouch(); 41 | // Outside an 'it' scope since we reuse this through the rest of the file. 42 | 43 | it('should return a server object', () => { 44 | expect(server).to.be.an('object'); 45 | }); 46 | 47 | describe('.allDatabases()', () => { 48 | it('should return an Observable which yields a list of databases', function * () { 49 | const databases = yield server.allDatabases().shouldGenerateOneValue(); 50 | expect(databases).to.be.an('array'); 51 | databases.forEach((dbName) => { expect(dbName).to.be.a('string'); }); 52 | expect(databases).to.include('_users'); 53 | }); 54 | }); 55 | 56 | describe('.createDatabase()', () => { 57 | it('should return an Observable which sends only onCompleted when done', function * () { 58 | yield server.createDatabase('test-rx-couch').shouldBeEmpty(); 59 | }); 60 | 61 | it('should succeed even if the database already exists', function * () { 62 | yield server.createDatabase('test-rx-couch').shouldBeEmpty(); 63 | }); 64 | 65 | it('should succeed even if the database already exists {failIfExists: false}', function * () { 66 | yield server.createDatabase('test-rx-couch').shouldBeEmpty(); 67 | }); 68 | 69 | it('should throw if database name is missing', () => { 70 | expect(() => server.createDatabase()).to.throw('rxCouch.createDatabase: dbName must be a string'); 71 | }); 72 | 73 | it('should throw if database name is empty', () => { 74 | expect(() => server.createDatabase('')).to.throw('rxCouch.createDatabase: illegal dbName'); 75 | }); 76 | 77 | it('should throw if database name is illegal', () => { 78 | expect(() => server.createDatabase('dontUppercaseMe')).to.throw('rxCouch.createDatabase: illegal dbName'); 79 | }); 80 | 81 | it('should throw if database name starts with underscore', () => { 82 | expect(() => server.createDatabase('_users')).to.throw('rxCouch.createDatabase: illegal dbName'); 83 | }); 84 | 85 | it('should throw if options is present, but not an object', () => { 86 | expect(() => server.createDatabase('x', 42)).to.throw('rxCouch.createDatabase: options, if present, must be an object'); 87 | }); 88 | 89 | it('should throw if options.failIfExists is present, but not a boolean', () => { 90 | expect(() => server.createDatabase('x', {failIfExists: 'bogus'})).to.throw('rxCouch.createDatabase: options.failIfExists, if present, must be a boolean'); 91 | }); 92 | 93 | it('should actually create a new database', function * () { 94 | const dbsAfterCreate = yield (Rx.Observable.concat( 95 | server.createDatabase('test-rx-couch'), 96 | server.allDatabases())).shouldGenerateOneValue(); 97 | 98 | expect(dbsAfterCreate).to.be.an('array'); 99 | expect(dbsAfterCreate).to.include('test-rx-couch'); 100 | }); 101 | 102 | it('should signal an error if database already exists (but only if so requested)', function * () { 103 | const err = yield server.createDatabase('test-rx-couch', {failIfExists: true}).shouldThrow(); 104 | expect(err.message).to.equal('HTTP Error 412 on http://localhost:5984/test-rx-couch: Precondition Failed'); 105 | }); 106 | 107 | it('should send an onError message if server yields unexpected result', function * () { 108 | nock('http://localhost:5979') 109 | .put('/test-rx-couch') 110 | .reply(500); 111 | 112 | const err = yield (new RxCouch('http://localhost:5979').createDatabase('test-rx-couch')).shouldThrow(); 113 | expect(err.message).to.equal('HTTP Error 500 on http://localhost:5979/test-rx-couch: Internal Server Error'); 114 | }); 115 | }); 116 | 117 | describe('.replicate()', () => { 118 | nock.cleanAll(); 119 | 120 | const srcDb = server.db('test-rx-couch-clone-source'); 121 | 122 | before('create test databases', function * () { 123 | yield server.createDatabase('test-rx-couch-clone-source').shouldBeEmpty(); 124 | yield server.createDatabase('test-rx-couch-clone-target').shouldBeEmpty(); 125 | let putObject = {_id: 'testing234', foo: 'bar'}; 126 | yield srcDb.put(putObject).shouldGenerateOneValue(); 127 | }); 128 | 129 | after('destroy test databases', function * () { 130 | yield server.deleteDatabase('test-rx-couch-clone-source').shouldBeEmpty(); 131 | yield server.deleteDatabase('test-rx-couch-clone-target').shouldBeEmpty(); 132 | }); 133 | 134 | it('should throw if options is missing', () => { 135 | expect(() => server.replicate()).to.throw('rxCouch.replicate: options must be an object'); 136 | }); 137 | 138 | it('should throw if options is not an object', () => { 139 | expect(() => server.replicate('blah')).to.throw('rxCouch.replicate: options must be an object'); 140 | }); 141 | 142 | it('should return an Observable with status information', function * () { 143 | const replResult = yield server.replicate({ 144 | source: 'test-rx-couch-clone-source', 145 | target: 'test-rx-couch-clone-target' 146 | }).shouldGenerateOneValue(); 147 | 148 | expect(replResult).to.be.an('object'); 149 | expect(replResult.ok).to.equal(true); 150 | expect(replResult.history).to.be.an('array'); 151 | }); 152 | }); 153 | 154 | describe('.deleteDatabase()', () => { 155 | nock.cleanAll(); 156 | 157 | it('should return an Observable which sends only onCompleted when done', function * () { 158 | yield server.deleteDatabase('test-rx-couch').shouldBeEmpty(); 159 | }); 160 | 161 | it("should succeed even if the database doesn't already exist", function * () { 162 | yield server.deleteDatabase('test-rx-couch').shouldBeEmpty(); 163 | }); 164 | 165 | it('should throw if database name is missing', () => { 166 | expect(() => server.deleteDatabase()).to.throw('rxCouch.deleteDatabase: dbName must be a string'); 167 | }); 168 | 169 | it('should throw if database name is empty', () => { 170 | expect(() => server.deleteDatabase('')).to.throw('rxCouch.deleteDatabase: illegal dbName'); 171 | }); 172 | 173 | it('should throw if database name is illegal', () => { 174 | expect(() => server.deleteDatabase('noCapitalLetters')).to.throw('rxCouch.deleteDatabase: illegal dbName'); 175 | }); 176 | 177 | it('should throw if database name starts with underscore', () => { 178 | expect(() => server.deleteDatabase('_users')).to.throw('rxCouch.deleteDatabase: illegal dbName'); 179 | }); 180 | 181 | it('should actually delete the existing database', function * () { 182 | const dbsAfterDelete = yield (Rx.Observable.concat( 183 | server.deleteDatabase('test-rx-couch'), 184 | server.allDatabases())).shouldGenerateOneValue(); 185 | 186 | expect(dbsAfterDelete).to.be.an('array'); 187 | expect(dbsAfterDelete).to.not.include('test-rx-couch'); 188 | }); 189 | 190 | it('should send an onError message if server yields unexpected result', function * () { 191 | nock('http://localhost:5979') 192 | .delete('/test-rx-couch') 193 | .reply(500); 194 | 195 | const err = yield (new RxCouch('http://localhost:5979').deleteDatabase('test-rx-couch')).shouldThrow(); 196 | expect(err.message).to.equal('HTTP Error 500 on http://localhost:5979/test-rx-couch: Internal Server Error'); 197 | }); 198 | }); 199 | }); 200 | --------------------------------------------------------------------------------