hide-a-bed 4.2.0 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -14
- package/impl/changes.mjs +53 -0
- package/impl/errors.mjs +1 -1
- package/impl/query.mjs +14 -6
- package/impl/stream.mjs +2 -2
- package/impl/sugar/lock.mjs +70 -0
- package/impl/sugar/watch.mjs +154 -0
- package/index.mjs +23 -3
- package/integration/changes.mjs +60 -0
- package/integration/disconnect-watch.mjs +36 -0
- package/integration/watch.mjs +40 -0
- package/log.txt +84 -0
- package/package.json +3 -2
- package/schema/bind.mjs +8 -1
- package/schema/changes.mjs +59 -0
- package/schema/sugar/lock.mjs +50 -0
- package/schema/sugar/watch.mjs +29 -0
- package/types/changes-stream.d.ts +11 -0
- package/cjs/impl/bulk.cjs +0 -267
- package/cjs/impl/crud.cjs +0 -121
- package/cjs/impl/errors.cjs +0 -75
- package/cjs/impl/logger.cjs +0 -70
- package/cjs/impl/patch.cjs +0 -95
- package/cjs/impl/query.cjs +0 -110
- package/cjs/impl/queryBuilder.cjs +0 -99
- package/cjs/impl/retry.cjs +0 -54
- package/cjs/impl/stream.cjs +0 -121
- package/cjs/impl/trackedEmitter.cjs +0 -54
- package/cjs/impl/transactionErrors.cjs +0 -70
- package/cjs/index.cjs +0 -95
- package/cjs/schema/bind.cjs +0 -44
- package/cjs/schema/bulk.cjs +0 -88
- package/cjs/schema/config.cjs +0 -48
- package/cjs/schema/crud.cjs +0 -77
- package/cjs/schema/patch.cjs +0 -53
- package/cjs/schema/query.cjs +0 -62
- package/cjs/schema/stream.cjs +0 -42
- package/impl/bulk.d.mts +0 -11
- package/impl/bulk.d.mts.map +0 -1
- package/impl/crud.d.mts +0 -7
- package/impl/crud.d.mts.map +0 -1
- package/impl/errors.d.mts +0 -43
- package/impl/errors.d.mts.map +0 -1
- package/impl/logger.d.mts +0 -32
- package/impl/logger.d.mts.map +0 -1
- package/impl/patch.d.mts +0 -6
- package/impl/patch.d.mts.map +0 -1
- package/impl/query.d.mts +0 -195
- package/impl/query.d.mts.map +0 -1
- package/impl/queryBuilder.d.mts +0 -94
- package/impl/queryBuilder.d.mts.map +0 -1
- package/impl/retry.d.mts +0 -2
- package/impl/retry.d.mts.map +0 -1
- package/impl/stream.d.mts +0 -3
- package/impl/stream.d.mts.map +0 -1
- package/impl/trackedEmitter.d.mts +0 -8
- package/impl/trackedEmitter.d.mts.map +0 -1
- package/impl/transactionErrors.d.mts +0 -57
- package/impl/transactionErrors.d.mts.map +0 -1
- package/index.d.mts +0 -56
- package/index.d.mts.map +0 -1
- package/schema/bind.d.mts +0 -820
- package/schema/bind.d.mts.map +0 -1
- package/schema/bulk.d.mts +0 -910
- package/schema/bulk.d.mts.map +0 -1
- package/schema/config.d.mts +0 -79
- package/schema/config.d.mts.map +0 -1
- package/schema/crud.d.mts +0 -491
- package/schema/crud.d.mts.map +0 -1
- package/schema/patch.d.mts +0 -255
- package/schema/patch.d.mts.map +0 -1
- package/schema/query.d.mts +0 -406
- package/schema/query.d.mts.map +0 -1
- package/schema/stream.d.mts +0 -211
- package/schema/stream.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
API
|
|
2
|
-
|
|
1
|
+
### API Quick Reference
|
|
2
|
+
|
|
3
|
+
🍭 denotes a *Sugar* api - helps makes some tasks sweet and easy, but may hide some complexities you might want to deal with.
|
|
4
|
+
|
|
5
|
+
| Document Operations | Bulk Operations | View Operations | Changes Feed |
|
|
6
|
+
|-------------------|-----------------|-----------------|-----------------|
|
|
7
|
+
| [`get()`](#get) | [`bulkGet()`](#bulkget) | [`query()`](#query) | [`changes()`](#changes) |
|
|
8
|
+
| [`put()`](#put) | [`bulkSave()`](#bulksave) | [`queryStream()`](#querystream) | [`watchDocs()`](#watchDocs) 🍭 |
|
|
9
|
+
| [`patch()`](#patch) 🍭 | [`bulkRemove()`](#bulkremove) | | |
|
|
10
|
+
| [`patchDangerously()`](#patchdangerously) 🍭 | [`bulkGetDictionary()`](#bulkgetdictionary) 🍭 | | |
|
|
11
|
+
| [`getAtRev()`](#getatrev) 🍭 | [`bulkSaveTransaction()`](#bulksavetransaction) 🍭 | | |
|
|
12
|
+
| [`createLock()`](#createLock) 🍭 | | | |
|
|
13
|
+
| [`removeLock()`](#removeLock) 🍭 | | | |
|
|
14
|
+
|
|
15
|
+
And some utility apis
|
|
16
|
+
|
|
17
|
+
- [`createQuery()`](#createquery) 🍭
|
|
18
|
+
- [`withRetry()`](#withretry)
|
|
19
|
+
|
|
3
20
|
|
|
4
21
|
### Setup
|
|
5
22
|
|
|
@@ -26,16 +43,6 @@ const db = bindConfig(process.env)
|
|
|
26
43
|
const doc = db.get('doc-123')
|
|
27
44
|
```
|
|
28
45
|
|
|
29
|
-
### API Quick Reference
|
|
30
|
-
|
|
31
|
-
| Document Operations | Bulk Operations | View Operations |
|
|
32
|
-
|-------------------|-----------------|-----------------|
|
|
33
|
-
| [`get()`](#get) | [`bulkGet()`](#bulkget) | [`query()`](#query) |
|
|
34
|
-
| [`put()`](#put) | [`bulkSave()`](#bulksave) | [`queryStream()`](#querystream) |
|
|
35
|
-
| [`patch()`](#patch) | [`bulkRemove()`](#bulkremove) | [`createQuery()`](#createquery) |
|
|
36
|
-
| [`patchDangerously()`](#patchdangerously) | [`bulkGetDictionary()`](#bulkgetdictionary) | |
|
|
37
|
-
| [`getAtRev()`](#getatrev) | [`bulkSaveTransaction()`](#bulksavetransaction) | |
|
|
38
|
-
|
|
39
46
|
### Document Operations
|
|
40
47
|
|
|
41
48
|
#### get
|
|
@@ -163,6 +170,52 @@ const doc = await getAtRev(config, 'doc-123', '2-fsdjfsdakljfsajlksd')
|
|
|
163
170
|
console.log(doc._id, doc._rev)
|
|
164
171
|
```
|
|
165
172
|
|
|
173
|
+
#### createLock
|
|
174
|
+
|
|
175
|
+
Create a lock document to try and prevent concurrent modifications.
|
|
176
|
+
|
|
177
|
+
Note this does not internally lock the document that is referenced by the id. People can still mutate it with
|
|
178
|
+
all the other document mutation functions. This should just be used at an app level to coordinate access
|
|
179
|
+
on long running document editing.
|
|
180
|
+
|
|
181
|
+
**Parameters:**
|
|
182
|
+
- `config`: Object with `couch` URL string
|
|
183
|
+
- `docId`: Document ID string to lock
|
|
184
|
+
- `options`: Lock options object:
|
|
185
|
+
- `enableLocking`: Boolean to enable/disable locking (default: true)
|
|
186
|
+
- `username`: String identifying who created the lock
|
|
187
|
+
|
|
188
|
+
Returns a Promise resolving to boolean indicating if lock was created successfully.
|
|
189
|
+
|
|
190
|
+
#### removeLock
|
|
191
|
+
|
|
192
|
+
Remove a lock from a document.
|
|
193
|
+
|
|
194
|
+
**Parameters:**
|
|
195
|
+
- `config`: Object with `couch` URL string
|
|
196
|
+
- `docId`: Document ID string to unlock
|
|
197
|
+
- `options`: Lock options object:
|
|
198
|
+
- `enableLocking`: Boolean to enable/disable locking (default: true)
|
|
199
|
+
- `username`: String identifying who is removing the lock
|
|
200
|
+
|
|
201
|
+
Only the user who created the lock can remove it.
|
|
202
|
+
|
|
203
|
+
```javascript
|
|
204
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
205
|
+
const options = {
|
|
206
|
+
enableLocking: true,
|
|
207
|
+
username: 'alice'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const locked = await createLock(config, 'doc-123', options)
|
|
211
|
+
if (locked) {
|
|
212
|
+
// Document is now locked for exclusive access
|
|
213
|
+
// Perform your updates here
|
|
214
|
+
await removeLock(config, 'doc-123', options)
|
|
215
|
+
}
|
|
216
|
+
// Lock is now removed if it existed and was owned by 'alice'
|
|
217
|
+
```
|
|
218
|
+
|
|
166
219
|
### Bulk Operations
|
|
167
220
|
|
|
168
221
|
#### bulkSave
|
|
@@ -230,7 +283,7 @@ const results = await bulkRemove(config, ids)
|
|
|
230
283
|
|
|
231
284
|
#### bulkGetDictionary
|
|
232
285
|
|
|
233
|
-
Adds some convenience to bulkGet.
|
|
286
|
+
Adds some convenience to bulkGet. Organizes found and notFound documents into properties that are {id:result}. This makes it easy to deal with the results.
|
|
234
287
|
|
|
235
288
|
**Parameters:**
|
|
236
289
|
- `config`: Object with `couch` URL string
|
|
@@ -426,6 +479,115 @@ const init = async () => {
|
|
|
426
479
|
}
|
|
427
480
|
init()
|
|
428
481
|
```
|
|
482
|
+
|
|
483
|
+
Want to consume this in the browser? I'd recomment https://www.npmjs.com/package/ndjson-readablestream
|
|
484
|
+
here is a react component that consumes it https://github.com/Azure-Samples/azure-search-openai-demo/pull/532/files#diff-506debba46b93087dc46a916384e56392808bcc02a99d9291557f3e674d4ad6c
|
|
485
|
+
|
|
486
|
+
#### changes()
|
|
487
|
+
|
|
488
|
+
Subscribe to the CouchDB changes feed to receive real-time updates.
|
|
489
|
+
|
|
490
|
+
**Parameters:**
|
|
491
|
+
- `config`: Object with `couch` URL string
|
|
492
|
+
- 'onChange': function called for each change
|
|
493
|
+
- `options`: Optional object with parameters:
|
|
494
|
+
- `since`: String or number indicating where to start from ('now' or update sequence number)
|
|
495
|
+
- `include_docs`: Boolean to include full documents
|
|
496
|
+
- `filter`: String name of design document filter function
|
|
497
|
+
- Other standard CouchDB changes feed parameters
|
|
498
|
+
|
|
499
|
+
Returns an EventEmitter that emits 'change' events with change objects.
|
|
500
|
+
|
|
501
|
+
```javascript
|
|
502
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
503
|
+
const options = {
|
|
504
|
+
since: 'now',
|
|
505
|
+
include_docs: true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const onChange = change => {
|
|
509
|
+
console.log('Document changed:', change.id)
|
|
510
|
+
console.log('New revision:', change.changes[0].rev)
|
|
511
|
+
if (change.doc) {
|
|
512
|
+
console.log('Document contents:', change.doc)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const feed = await changes(config, onChange, options)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
// Stop listening to changes
|
|
519
|
+
feed.stop()
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
The changes feed is useful for:
|
|
523
|
+
- Building real-time applications
|
|
524
|
+
- Keeping local data in sync with CouchDB
|
|
525
|
+
- Triggering actions when documents change
|
|
526
|
+
- Implementing replication
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
#### watchDocs()
|
|
530
|
+
|
|
531
|
+
Watch specific documents for changes in real-time.
|
|
532
|
+
|
|
533
|
+
**Parameters:**
|
|
534
|
+
- `config`: Object with `couch` URL string
|
|
535
|
+
- `docIds`: String or array of document IDs to watch (max 100
|
|
536
|
+
- `onChange`: Function called for each change
|
|
537
|
+
- `options`: Optional object with parameters:
|
|
538
|
+
- `include_docs`: Boolean to include full documents (defaul
|
|
539
|
+
false)
|
|
540
|
+
- `maxRetries`: Maximum reconnection attempts (default: 10)
|
|
541
|
+
- `initialDelay`: Initial reconnection delay in ms (default
|
|
542
|
+
1000)
|
|
543
|
+
- `maxDelay`: Maximum reconnection delay in ms (default:
|
|
544
|
+
30000)
|
|
545
|
+
|
|
546
|
+
Returns an EventEmitter that emits:
|
|
547
|
+
- 'change' events with change objects
|
|
548
|
+
- 'error' events when max retries reached
|
|
549
|
+
- 'end' events with last sequence number
|
|
550
|
+
|
|
551
|
+
```javascript
|
|
552
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
553
|
+
|
|
554
|
+
// Watch a single document
|
|
555
|
+
const feed = await watchDocs(config, 'doc123', change => {
|
|
556
|
+
console.log('Document changed:', change.id)
|
|
557
|
+
console.log('New revision:', change.changes[0].rev)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Watch multiple documents with full doc content
|
|
561
|
+
const feed = await watchDocs(
|
|
562
|
+
config,
|
|
563
|
+
['doc1', 'doc2', 'doc3'],
|
|
564
|
+
change => {
|
|
565
|
+
if (change.doc) {
|
|
566
|
+
console.log('Updated document:', change.doc)
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
{ include_docs: true }
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
// Handle errors
|
|
573
|
+
feed.on('error', error => {
|
|
574
|
+
console.error('Watch error:', error)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// Handle end of feed
|
|
578
|
+
feed.on('end', ({ lastSeq }) => {
|
|
579
|
+
console.log('Feed ended at sequence:', lastSeq)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
// Stop watching
|
|
583
|
+
feed.stop()
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
The watchDocs feed is useful for:
|
|
587
|
+
- Building real-time applications focused on specific documents
|
|
588
|
+
- Triggering actions when particular documents change
|
|
589
|
+
- Maintaining cached copies of frequently accessed documents
|
|
590
|
+
|
|
429
591
|
Advanced Config Options
|
|
430
592
|
=======================
|
|
431
593
|
|
|
@@ -500,4 +662,3 @@ Each operation logs appropriate information at these levels:
|
|
|
500
662
|
- info: Operation start/completion
|
|
501
663
|
- debug: Detailed operation information
|
|
502
664
|
|
|
503
|
-
|
package/impl/changes.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import needle from 'needle'
|
|
3
|
+
import { EventEmitter } from 'events'
|
|
4
|
+
import { Changes } from '../schema/changes.mjs'
|
|
5
|
+
|
|
6
|
+
/** @typedef {{
|
|
7
|
+
* seq: string|number,
|
|
8
|
+
* id: string,
|
|
9
|
+
* changes: Array<{rev: string}>,
|
|
10
|
+
* deleted?: boolean,
|
|
11
|
+
* doc?: any
|
|
12
|
+
* }} ChangeInfo */
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
import ChangesStream from 'changes-stream'
|
|
15
|
+
|
|
16
|
+
/** @type { import('../schema/changes.mjs').ChangesSchema } */
|
|
17
|
+
export const changes = Changes.implement(async (config, onChange, options = {}) => {
|
|
18
|
+
const emitter = new EventEmitter()
|
|
19
|
+
options.db = config.couch
|
|
20
|
+
if (options.since && options.since === 'now') {
|
|
21
|
+
const opts = {
|
|
22
|
+
json: true,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// request the GET on config.couch and get the update_seq
|
|
28
|
+
const resp = await needle('get', config.couch, opts)
|
|
29
|
+
options.since = resp.body.update_seq
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const changes = ChangesStream(options)
|
|
33
|
+
|
|
34
|
+
changes.on('readable', () => {
|
|
35
|
+
const change = changes.read()
|
|
36
|
+
if (change.results && Array.isArray(change.results)) {
|
|
37
|
+
// emit each one seperate
|
|
38
|
+
change.results.forEach((/** @type {ChangeInfo} */ c) => emitter.emit('change', c))
|
|
39
|
+
} else emitter.emit('change', change)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Bind the provided change listener
|
|
43
|
+
emitter.on('change', onChange)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
47
|
+
removeListener: (event, listener) => emitter.removeListener(event, listener),
|
|
48
|
+
stop: () => {
|
|
49
|
+
changes.destroy()
|
|
50
|
+
emitter.removeAllListeners()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
package/impl/errors.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export class NotFoundError extends Error {
|
|
|
11
11
|
* @param {string} docId - The ID of the document that wasn't found
|
|
12
12
|
* @param {string} [message] - Optional error message
|
|
13
13
|
*/
|
|
14
|
-
constructor(docId, message = 'Document not found') {
|
|
14
|
+
constructor (docId, message = 'Document not found') {
|
|
15
15
|
super(message)
|
|
16
16
|
this.name = 'NotFoundError'
|
|
17
17
|
this.docId = docId
|
package/impl/query.mjs
CHANGED
|
@@ -22,7 +22,6 @@ export const query = SimpleViewQuery.implement(async (config, view, options = {}
|
|
|
22
22
|
|
|
23
23
|
// @ts-ignore
|
|
24
24
|
let qs = queryString(options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
|
|
25
|
-
logger.debug('Generated query string:', qs)
|
|
26
25
|
let method = 'GET'
|
|
27
26
|
let payload = null
|
|
28
27
|
const opts = {
|
|
@@ -35,21 +34,30 @@ export const query = SimpleViewQuery.implement(async (config, view, options = {}
|
|
|
35
34
|
// If keys are supplied, issue a POST to circumvent GET query string limits
|
|
36
35
|
// see http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
|
|
37
36
|
if (typeof options.keys !== 'undefined') {
|
|
38
|
-
const MAX_URL_LENGTH = 2000
|
|
37
|
+
const MAX_URL_LENGTH = 2000
|
|
39
38
|
// according to http://stackoverflow.com/a/417184/680742,
|
|
40
39
|
// the de facto URL length limit is 2000 characters
|
|
41
40
|
|
|
42
|
-
const
|
|
41
|
+
const _options = structuredClone(options)
|
|
42
|
+
delete _options.keys
|
|
43
|
+
qs = queryString(_options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
|
|
44
|
+
|
|
45
|
+
const keysAsString = `keys=${JSON.stringify(options.keys)}`
|
|
46
|
+
|
|
43
47
|
if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
|
|
44
48
|
// If the keys are short enough, do a GET. we do this to work around
|
|
45
49
|
// Safari not understanding 304s on POSTs (see pouchdb/pouchdb#1239)
|
|
46
|
-
|
|
50
|
+
method = 'GET'
|
|
51
|
+
if (qs.length > 0) qs += '&'
|
|
52
|
+
else qs = '?'
|
|
53
|
+
qs += keysAsString
|
|
47
54
|
} else {
|
|
48
|
-
method = 'POST'
|
|
49
|
-
payload = {keys: options.keys}
|
|
55
|
+
method = 'POST'
|
|
56
|
+
payload = { keys: options.keys }
|
|
50
57
|
}
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
logger.debug('Generated query string:', qs)
|
|
53
61
|
const url = `${config.couch}/${view}?${qs.toString()}`
|
|
54
62
|
// @ts-ignore
|
|
55
63
|
let results
|
package/impl/stream.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export const queryStream = (rawConfig, view, options, onRow) => new Promise((res
|
|
|
25
25
|
if (typeof options.keys !== 'undefined') {
|
|
26
26
|
const MAX_URL_LENGTH = 2000
|
|
27
27
|
const keysAsString = `keys=${encodeURIComponent(JSON.stringify(options.keys))}`
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
|
|
30
30
|
// If the keys are short enough, do a GET
|
|
31
31
|
qs += (qs[0] === '?' ? '&' : '?') + keysAsString
|
|
@@ -70,7 +70,7 @@ export const queryStream = (rawConfig, view, options, onRow) => new Promise((res
|
|
|
70
70
|
resolve(undefined) // all work should be done in the stream
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
const req = method === 'GET'
|
|
73
|
+
const req = method === 'GET'
|
|
74
74
|
? needle.get(url, opts)
|
|
75
75
|
: needle.post(url, payload, opts)
|
|
76
76
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CreateLock, RemoveLock } from '../../schema/sugar/lock.mjs'
|
|
2
|
+
import { put, get } from '../crud.mjs'
|
|
3
|
+
import { createLogger } from '../logger.mjs'
|
|
4
|
+
|
|
5
|
+
/** @type {import('../../schema/sugar/lock.mjs').CreateLockSchema} */
|
|
6
|
+
export const createLock = CreateLock.implement(async (config, docId, options) => {
|
|
7
|
+
const logger = createLogger(config)
|
|
8
|
+
|
|
9
|
+
if (!options.enableLocking) {
|
|
10
|
+
logger.debug('Locking disabled, returning true')
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const _id = `lock-${docId}`
|
|
15
|
+
const lock = {
|
|
16
|
+
_id,
|
|
17
|
+
type: 'lock',
|
|
18
|
+
locks: docId,
|
|
19
|
+
lockedAt: new Date().toISOString(),
|
|
20
|
+
lockedBy: options.username
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await put(config, lock)
|
|
25
|
+
logger.info(`Lock created for ${docId} by ${options.username}`)
|
|
26
|
+
return result.ok === true
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.status === 409) {
|
|
29
|
+
logger.warn(`Lock conflict for ${docId} - already locked`)
|
|
30
|
+
} else {
|
|
31
|
+
logger.error(`Error creating lock for ${docId}:`, error)
|
|
32
|
+
}
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** @type {import('../../schema/sugar/lock.mjs').RemoveLockSchema} */
|
|
38
|
+
export const removeLock = RemoveLock.implement(async (config, docId, options) => {
|
|
39
|
+
const logger = createLogger(config)
|
|
40
|
+
|
|
41
|
+
if (!options.enableLocking) {
|
|
42
|
+
logger.debug('Locking disabled, skipping unlock')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!docId) {
|
|
47
|
+
logger.warn('No docId provided for unlock')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const _id = `lock-${docId}`
|
|
52
|
+
const existingLock = await get(config, _id)
|
|
53
|
+
|
|
54
|
+
if (!existingLock) {
|
|
55
|
+
logger.debug(`No lock found for ${docId}`)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (existingLock.lockedBy !== options.username) {
|
|
60
|
+
logger.warn(`Cannot remove lock for ${docId} - owned by ${existingLock.lockedBy}`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await put(config, { ...existingLock, _deleted: true })
|
|
66
|
+
logger.info(`Lock removed for ${docId}`)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
logger.error(`Error removing lock for ${docId}:`, error)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import needle from 'needle'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { RetryableError } from '../errors.mjs'
|
|
4
|
+
import { createLogger } from '../logger.mjs'
|
|
5
|
+
import { sleep } from '../patch.mjs'
|
|
6
|
+
import { WatchDocs } from '../../schema/sugar/watch.mjs'
|
|
7
|
+
|
|
8
|
+
// watch the doc for any changes
|
|
9
|
+
export const watchDocs = WatchDocs.implement((config, docIds, onChange, options = {}) => {
|
|
10
|
+
const logger = createLogger(config)
|
|
11
|
+
const emitter = new EventEmitter()
|
|
12
|
+
let lastSeq = null || 'now'
|
|
13
|
+
let stopping = false
|
|
14
|
+
let retryCount = 0
|
|
15
|
+
let currentRequest = null
|
|
16
|
+
const maxRetries = options.maxRetries || 10
|
|
17
|
+
const initialDelay = options.initialDelay || 1000
|
|
18
|
+
const maxDelay = options.maxDelay || 30000
|
|
19
|
+
|
|
20
|
+
const _docIds = Array.isArray(docIds) ? docIds : [docIds]
|
|
21
|
+
if (_docIds.length === 0) throw new Error('docIds must be a non-empty array')
|
|
22
|
+
if (_docIds.length > 100) throw new Error('docIds must be an array of 100 or fewer elements')
|
|
23
|
+
|
|
24
|
+
const connect = async () => {
|
|
25
|
+
if (stopping) return
|
|
26
|
+
|
|
27
|
+
const feed = 'continuous'
|
|
28
|
+
const includeDocs = options.include_docs ?? false
|
|
29
|
+
const ids = _docIds.join('","')
|
|
30
|
+
const url = `${config.couch}/_changes?feed=${feed}&since=${lastSeq}&include_docs=${includeDocs}&filter=_doc_ids&doc_ids=["${ids}"]`
|
|
31
|
+
|
|
32
|
+
const opts = {
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
parse_response: false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let buffer = ''
|
|
38
|
+
currentRequest = needle.get(url, opts)
|
|
39
|
+
|
|
40
|
+
currentRequest.on('data', chunk => {
|
|
41
|
+
buffer += chunk.toString()
|
|
42
|
+
const lines = buffer.split('\n')
|
|
43
|
+
|
|
44
|
+
// Keep the last partial line in the buffer
|
|
45
|
+
buffer = lines.pop() || ''
|
|
46
|
+
|
|
47
|
+
// Process complete lines
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (line.trim()) {
|
|
50
|
+
try {
|
|
51
|
+
const change = JSON.parse(line)
|
|
52
|
+
if (!change.id) return null // ignore just last_seq
|
|
53
|
+
logger.debug(`Change detected, watching [${_docIds}]`, change)
|
|
54
|
+
lastSeq = change.seq || change.last_seq
|
|
55
|
+
emitter.emit('change', change)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.error('Error parsing change:', err, 'Line:', line)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
currentRequest.on('response', response => {
|
|
64
|
+
logger.debug(`Received response with status code, watching [${_docIds}]: ${response.statusCode}`)
|
|
65
|
+
if (RetryableError.isRetryableStatusCode(response.statusCode)) {
|
|
66
|
+
logger.warn(`Retryable status code received: ${response.statusCode}`)
|
|
67
|
+
currentRequest.abort()
|
|
68
|
+
handleReconnect()
|
|
69
|
+
} else {
|
|
70
|
+
// Reset retry count on successful connection
|
|
71
|
+
retryCount = 0
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
currentRequest.on('error', async err => {
|
|
76
|
+
if (stopping) {
|
|
77
|
+
logger.info('stopping in progress, ignore stream error')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
logger.error(`Network error during stream, watching [${_docIds}]:`, err.toString())
|
|
81
|
+
try {
|
|
82
|
+
RetryableError.handleNetworkError(err)
|
|
83
|
+
} catch (filteredError) {
|
|
84
|
+
if (filteredError instanceof RetryableError) {
|
|
85
|
+
logger.info(`Retryable error, watching [${_docIds}]:`, filteredError.toString())
|
|
86
|
+
handleReconnect()
|
|
87
|
+
} else {
|
|
88
|
+
logger.error(`Non-retryable error, watching [${_docIds}]`, filteredError.toString())
|
|
89
|
+
emitter.emit('error', filteredError)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
currentRequest.on('end', () => {
|
|
95
|
+
// Process any remaining data in buffer
|
|
96
|
+
if (buffer.trim()) {
|
|
97
|
+
try {
|
|
98
|
+
const change = JSON.parse(buffer)
|
|
99
|
+
logger.debug('Final change detected:', change)
|
|
100
|
+
emitter.emit('change', change)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error('Error parsing final change:', err)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
logger.info('Stream completed. Last seen seq: ', lastSeq)
|
|
106
|
+
emitter.emit('end', { lastSeq })
|
|
107
|
+
|
|
108
|
+
// If the stream ends and we're not stopping, attempt to reconnect
|
|
109
|
+
if (!stopping) {
|
|
110
|
+
handleReconnect()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const handleReconnect = async () => {
|
|
116
|
+
if (stopping || retryCount >= maxRetries) {
|
|
117
|
+
if (retryCount >= maxRetries) {
|
|
118
|
+
logger.error(`Max retries (${maxRetries}) reached, giving up`)
|
|
119
|
+
emitter.emit('error', new Error('Max retries reached'))
|
|
120
|
+
}
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const delay = Math.min(initialDelay * Math.pow(2, retryCount), maxDelay)
|
|
125
|
+
retryCount++
|
|
126
|
+
|
|
127
|
+
logger.info(`Attempting to reconnect in ${delay}ms (attempt ${retryCount} of ${maxRetries})`)
|
|
128
|
+
await sleep(delay)
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
connect()
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.error('Error during reconnection:', err)
|
|
134
|
+
handleReconnect()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Start initial connection
|
|
139
|
+
connect()
|
|
140
|
+
|
|
141
|
+
// Bind the provided change listener
|
|
142
|
+
emitter.on('change', onChange)
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
146
|
+
removeListener: (event, listener) => emitter.removeListener(event, listener),
|
|
147
|
+
stop: () => {
|
|
148
|
+
stopping = true
|
|
149
|
+
if (currentRequest) currentRequest.abort()
|
|
150
|
+
emitter.emit('end', { lastSeq })
|
|
151
|
+
emitter.removeAllListeners()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})
|
package/index.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// @ts-check */
|
|
2
2
|
import { bulkGet, bulkSave, bulkRemove, bulkGetDictionary, bulkSaveTransaction } from './impl/bulk.mjs'
|
|
3
3
|
import { get, put, getAtRev } from './impl/crud.mjs'
|
|
4
|
+
import { changes } from './impl/changes.mjs'
|
|
4
5
|
import { patch, patchDangerously } from './impl/patch.mjs'
|
|
6
|
+
import { createLock, removeLock } from './impl/sugar/lock.mjs'
|
|
7
|
+
import { watchDocs } from './impl/sugar/watch.mjs'
|
|
5
8
|
import { query } from './impl/query.mjs'
|
|
6
9
|
import { queryStream } from './impl/stream.mjs'
|
|
7
10
|
import { createQuery } from './impl/queryBuilder.mjs'
|
|
@@ -9,8 +12,11 @@ import { withRetry } from './impl/retry.mjs'
|
|
|
9
12
|
import { BulkSave, BulkGet, BulkRemove, BulkGetDictionary, BulkSaveTransaction } from './schema/bulk.mjs'
|
|
10
13
|
import { CouchConfig } from './schema/config.mjs'
|
|
11
14
|
import { SimpleViewQuery, SimpleViewQueryResponse } from './schema/query.mjs'
|
|
15
|
+
import { Changes, ChangesOptions, ChangesResponse } from './schema/changes.mjs'
|
|
12
16
|
import { SimpleViewQueryStream, OnRow } from './schema/stream.mjs'
|
|
13
17
|
import { Patch, PatchDangerously } from './schema/patch.mjs'
|
|
18
|
+
import { Lock, LockOptions, CreateLock, RemoveLock } from './schema/sugar/lock.mjs'
|
|
19
|
+
import { WatchDocs } from './schema/sugar/watch.mjs'
|
|
14
20
|
import { CouchDoc, CouchDocResponse, CouchPut, CouchGet, CouchGetAtRev } from './schema/crud.mjs'
|
|
15
21
|
import { Bind } from './schema/bind.mjs'
|
|
16
22
|
|
|
@@ -32,7 +38,15 @@ const schema = {
|
|
|
32
38
|
Patch,
|
|
33
39
|
PatchDangerously,
|
|
34
40
|
CouchGetAtRev,
|
|
35
|
-
Bind
|
|
41
|
+
Bind,
|
|
42
|
+
Lock,
|
|
43
|
+
WatchDocs,
|
|
44
|
+
LockOptions,
|
|
45
|
+
CreateLock,
|
|
46
|
+
RemoveLock,
|
|
47
|
+
Changes,
|
|
48
|
+
ChangesOptions,
|
|
49
|
+
ChangesResponse
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** @type { import('./schema/bind.mjs').BindSchema } */
|
|
@@ -60,7 +74,11 @@ const bindConfig = Bind.implement((
|
|
|
60
74
|
patchDangerously: patchDangerously.bind(null, config), // patchDangerously not included in retry
|
|
61
75
|
bulkRemove: config.bindWithRetry ? withRetry(bulkRemove.bind(null, config), retryOptions) : bulkRemove.bind(null, config),
|
|
62
76
|
bulkGetDictionary: config.bindWithRetry ? withRetry(bulkGetDictionary.bind(null, config), retryOptions) : bulkGetDictionary.bind(null, config),
|
|
63
|
-
bulkSaveTransaction: bulkSaveTransaction.bind(null, config)
|
|
77
|
+
bulkSaveTransaction: bulkSaveTransaction.bind(null, config),
|
|
78
|
+
createLock: createLock.bind(null, config),
|
|
79
|
+
removeLock: removeLock.bind(null, config),
|
|
80
|
+
watchDocs: watchDocs.bind(null, config),
|
|
81
|
+
changes: changes.bind(null, config)
|
|
64
82
|
}
|
|
65
83
|
})
|
|
66
84
|
|
|
@@ -83,5 +101,7 @@ export {
|
|
|
83
101
|
|
|
84
102
|
bindConfig,
|
|
85
103
|
withRetry,
|
|
86
|
-
createQuery
|
|
104
|
+
createQuery,
|
|
105
|
+
createLock,
|
|
106
|
+
removeLock
|
|
87
107
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TrackedEmitter } from '../impl/trackedEmitter.mjs'
|
|
2
|
+
import test from 'tap'
|
|
3
|
+
import { spawn } from 'child_process'
|
|
4
|
+
import { bindConfig } from '../index.mjs'
|
|
5
|
+
import needle from 'needle'
|
|
6
|
+
|
|
7
|
+
let DB_URL = `https://admin:iEZCEQhVR9PVCmYuZ5Bv@redman-couchdb.staging.brivity.io/testdb`
|
|
8
|
+
|
|
9
|
+
const config = {
|
|
10
|
+
couch: DB_URL,
|
|
11
|
+
bindWithRetry: true,
|
|
12
|
+
logger: (level, ...args) => {
|
|
13
|
+
console.log(`[${level.toUpperCase()}]`, ...args)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
test.test('changes tests', async t => {
|
|
17
|
+
await needle('put', DB_URL)
|
|
18
|
+
t.teardown(async () => {
|
|
19
|
+
await needle('delete', DB_URL)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const db = bindConfig(config)
|
|
23
|
+
|
|
24
|
+
t.test('basic changes feed', t => new Promise(async (resolve) => {
|
|
25
|
+
const onChange = (change) => {
|
|
26
|
+
t.equal(change.id, 'test-changes-doc', 'change notification received')
|
|
27
|
+
changesEmitter.stop()
|
|
28
|
+
t.end()
|
|
29
|
+
resolve()
|
|
30
|
+
}
|
|
31
|
+
const changesEmitter = await db.changes(onChange, { since: 'now', feed: 'continuous' })
|
|
32
|
+
t.ok(changesEmitter.on, 'changes emitter has on method')
|
|
33
|
+
t.ok(changesEmitter.removeListener, 'changes emitter has removeListener method')
|
|
34
|
+
t.ok(changesEmitter.stop, 'changes emitter has stop method')
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 1000)) // Give it time to start
|
|
36
|
+
// Create a document to trigger a change
|
|
37
|
+
await db.put({ _id: 'test-changes-doc', data: 'test' })
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
t.test('document id', t => new Promise(async (resolve) => {
|
|
41
|
+
const opts = {
|
|
42
|
+
since: 'now',
|
|
43
|
+
include_docs: true,
|
|
44
|
+
feed: 'continuous'
|
|
45
|
+
}
|
|
46
|
+
const onChange = (change) => {
|
|
47
|
+
console.log('got a change', change)
|
|
48
|
+
if (change.id === 'test-a') {
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
resolve()
|
|
51
|
+
}, 1000)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const changesEmitter = await db.changes(onChange, opts)
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 4000)) // Give it time to start
|
|
56
|
+
// Create a document to trigger a change
|
|
57
|
+
await db.put({ _id: 'test-changes-doc-2', data: 'test' })
|
|
58
|
+
await db.put({ _id: 'test-a', data: 'test' })
|
|
59
|
+
}))
|
|
60
|
+
})
|