hide-a-bed 4.1.2 → 5.0.0
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 +160 -2
- package/dualmode.config.json +11 -0
- package/impl/changes.mjs +58 -0
- package/impl/query.mjs +37 -7
- package/impl/stream.mjs +21 -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/package.json +6 -5
- package/schema/bind.mjs +8 -1
- package/schema/changes.mjs +59 -0
- package/schema/query.mjs +1 -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 -98
- package/cjs/impl/queryBuilder.cjs +0 -99
- package/cjs/impl/retry.cjs +0 -54
- package/cjs/impl/stream.cjs +0 -109
- 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 -61
- 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 -182
- 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 -810
- 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 -397
- package/schema/query.d.mts.map +0 -1
- package/schema/stream.d.mts +0 -205
- package/schema/stream.d.mts.map +0 -1
package/README.md
CHANGED
|
@@ -36,6 +36,14 @@ const doc = db.get('doc-123')
|
|
|
36
36
|
| [`patchDangerously()`](#patchdangerously) | [`bulkGetDictionary()`](#bulkgetdictionary) | |
|
|
37
37
|
| [`getAtRev()`](#getatrev) | [`bulkSaveTransaction()`](#bulksavetransaction) | |
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
Some *Sugar* API helpers
|
|
41
|
+
|
|
42
|
+
- [`createLock()`](#createLock)
|
|
43
|
+
- [`removeLock()`](#removeLock)
|
|
44
|
+
- [`changes()`](#changes)
|
|
45
|
+
- [`watchDocs()`](#watchDocs)
|
|
46
|
+
|
|
39
47
|
### Document Operations
|
|
40
48
|
|
|
41
49
|
#### get
|
|
@@ -163,6 +171,52 @@ const doc = await getAtRev(config, 'doc-123', '2-fsdjfsdakljfsajlksd')
|
|
|
163
171
|
console.log(doc._id, doc._rev)
|
|
164
172
|
```
|
|
165
173
|
|
|
174
|
+
#### createLock
|
|
175
|
+
|
|
176
|
+
Create a lock document to try and prevent concurrent modifications.
|
|
177
|
+
|
|
178
|
+
Note this does not internally lock the document that is referenced by the id. People can still mutate it with
|
|
179
|
+
all the other document mutation functions. This should just be used at an app level to coordinate access
|
|
180
|
+
on long running document editing.
|
|
181
|
+
|
|
182
|
+
**Parameters:**
|
|
183
|
+
- `config`: Object with `couch` URL string
|
|
184
|
+
- `docId`: Document ID string to lock
|
|
185
|
+
- `options`: Lock options object:
|
|
186
|
+
- `enableLocking`: Boolean to enable/disable locking (default: true)
|
|
187
|
+
- `username`: String identifying who created the lock
|
|
188
|
+
|
|
189
|
+
Returns a Promise resolving to boolean indicating if lock was created successfully.
|
|
190
|
+
|
|
191
|
+
#### removeLock
|
|
192
|
+
|
|
193
|
+
Remove a lock from a document.
|
|
194
|
+
|
|
195
|
+
**Parameters:**
|
|
196
|
+
- `config`: Object with `couch` URL string
|
|
197
|
+
- `docId`: Document ID string to unlock
|
|
198
|
+
- `options`: Lock options object:
|
|
199
|
+
- `enableLocking`: Boolean to enable/disable locking (default: true)
|
|
200
|
+
- `username`: String identifying who is removing the lock
|
|
201
|
+
|
|
202
|
+
Only the user who created the lock can remove it.
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
206
|
+
const options = {
|
|
207
|
+
enableLocking: true,
|
|
208
|
+
username: 'alice'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const locked = await createLock(config, 'doc-123', options)
|
|
212
|
+
if (locked) {
|
|
213
|
+
// Document is now locked for exclusive access
|
|
214
|
+
// Perform your updates here
|
|
215
|
+
await removeLock(config, 'doc-123', options)
|
|
216
|
+
}
|
|
217
|
+
// Lock is now removed if it existed and was owned by 'alice'
|
|
218
|
+
```
|
|
219
|
+
|
|
166
220
|
### Bulk Operations
|
|
167
221
|
|
|
168
222
|
#### bulkSave
|
|
@@ -181,7 +235,7 @@ const docs = [
|
|
|
181
235
|
{ _id: 'doc2', type: 'user', name: 'Bob' }
|
|
182
236
|
]
|
|
183
237
|
const results = await bulkSave(config, docs)
|
|
184
|
-
//
|
|
238
|
+
// [
|
|
185
239
|
// { ok: true, id: 'doc1', rev: '1-abc123' },
|
|
186
240
|
// { ok: true, id: 'doc2', rev: '1-def456' }
|
|
187
241
|
// ]
|
|
@@ -202,7 +256,7 @@ Not found documents will still have a row in the results, but the doc will be nu
|
|
|
202
256
|
const config = { couch: 'http://localhost:5984/mydb' }
|
|
203
257
|
const ids = ['doc1', 'doc2', 'doesNotExist']
|
|
204
258
|
const docs = await bulkGet(config, ids)
|
|
205
|
-
//
|
|
259
|
+
// rows: [
|
|
206
260
|
// { _id: 'doc1', _rev: '1-abc123', type: 'user', name: 'Alice' },
|
|
207
261
|
// { _id: 'doc2', _rev: '1-def456', type: 'user', name: 'Bob' },
|
|
208
262
|
// { key: 'notThereDoc', error: 'not_found' }
|
|
@@ -500,4 +554,108 @@ Each operation logs appropriate information at these levels:
|
|
|
500
554
|
- info: Operation start/completion
|
|
501
555
|
- debug: Detailed operation information
|
|
502
556
|
|
|
557
|
+
#### changes()
|
|
558
|
+
|
|
559
|
+
Subscribe to the CouchDB changes feed to receive real-time updates.
|
|
560
|
+
|
|
561
|
+
**Parameters:**
|
|
562
|
+
- `config`: Object with `couch` URL string
|
|
563
|
+
- 'onChange': function called for each change
|
|
564
|
+
- `options`: Optional object with parameters:
|
|
565
|
+
- `since`: String or number indicating where to start from ('now' or update sequence number)
|
|
566
|
+
- `include_docs`: Boolean to include full documents
|
|
567
|
+
- `filter`: String name of design document filter function
|
|
568
|
+
- Other standard CouchDB changes feed parameters
|
|
569
|
+
|
|
570
|
+
Returns an EventEmitter that emits 'change' events with change objects.
|
|
571
|
+
|
|
572
|
+
```javascript
|
|
573
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
574
|
+
const options = {
|
|
575
|
+
since: 'now',
|
|
576
|
+
include_docs: true
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const onChange = change => {
|
|
580
|
+
console.log('Document changed:', change.id)
|
|
581
|
+
console.log('New revision:', change.changes[0].rev)
|
|
582
|
+
if (change.doc) {
|
|
583
|
+
console.log('Document contents:', change.doc)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const feed = await changes(config, onChange, options)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
// Stop listening to changes
|
|
590
|
+
feed.stop()
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
The changes feed is useful for:
|
|
594
|
+
- Building real-time applications
|
|
595
|
+
- Keeping local data in sync with CouchDB
|
|
596
|
+
- Triggering actions when documents change
|
|
597
|
+
- Implementing replication
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
#### watchDocs()
|
|
601
|
+
|
|
602
|
+
Watch specific documents for changes in real-time.
|
|
603
|
+
|
|
604
|
+
**Parameters:**
|
|
605
|
+
- `config`: Object with `couch` URL string
|
|
606
|
+
- `docIds`: String or array of document IDs to watch (max 100
|
|
607
|
+
- `onChange`: Function called for each change
|
|
608
|
+
- `options`: Optional object with parameters:
|
|
609
|
+
- `include_docs`: Boolean to include full documents (defaul
|
|
610
|
+
false)
|
|
611
|
+
- `maxRetries`: Maximum reconnection attempts (default: 10)
|
|
612
|
+
- `initialDelay`: Initial reconnection delay in ms (default
|
|
613
|
+
1000)
|
|
614
|
+
- `maxDelay`: Maximum reconnection delay in ms (default:
|
|
615
|
+
30000)
|
|
616
|
+
|
|
617
|
+
Returns an EventEmitter that emits:
|
|
618
|
+
- 'change' events with change objects
|
|
619
|
+
- 'error' events when max retries reached
|
|
620
|
+
- 'end' events with last sequence number
|
|
621
|
+
|
|
622
|
+
```javascript
|
|
623
|
+
const config = { couch: 'http://localhost:5984/mydb' }
|
|
624
|
+
|
|
625
|
+
// Watch a single document
|
|
626
|
+
const feed = await watchDocs(config, 'doc123', change => {
|
|
627
|
+
console.log('Document changed:', change.id)
|
|
628
|
+
console.log('New revision:', change.changes[0].rev)
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
// Watch multiple documents with full doc content
|
|
632
|
+
const feed = await watchDocs(
|
|
633
|
+
config,
|
|
634
|
+
['doc1', 'doc2', 'doc3'],
|
|
635
|
+
change => {
|
|
636
|
+
if (change.doc) {
|
|
637
|
+
console.log('Updated document:', change.doc)
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
{ include_docs: true }
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
// Handle errors
|
|
644
|
+
feed.on('error', error => {
|
|
645
|
+
console.error('Watch error:', error)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// Handle end of feed
|
|
649
|
+
feed.on('end', ({ lastSeq }) => {
|
|
650
|
+
console.log('Feed ended at sequence:', lastSeq)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
// Stop watching
|
|
654
|
+
feed.stop()
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The watchDocs feed is useful for:
|
|
658
|
+
- Building real-time applications focused on specific documents
|
|
659
|
+
- Triggering actions when particular documents change
|
|
660
|
+
- Maintaining cached copies of frequently accessed documents
|
|
503
661
|
|
package/impl/changes.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
import { createLogger } from './logger.mjs'
|
|
16
|
+
import { sleep } from './patch.mjs'
|
|
17
|
+
|
|
18
|
+
const MAX_RETRY_DELAY = 30000 // 30 seconds
|
|
19
|
+
|
|
20
|
+
/** @type { import('../schema/changes.mjs').ChangesSchema } */
|
|
21
|
+
export const changes = Changes.implement(async (config, onChange, options = {}) => {
|
|
22
|
+
const emitter = new EventEmitter()
|
|
23
|
+
const logger = createLogger(config)
|
|
24
|
+
options.db = config.couch
|
|
25
|
+
if (options.since && options.since === 'now') {
|
|
26
|
+
const opts = {
|
|
27
|
+
json: true,
|
|
28
|
+
headers: {
|
|
29
|
+
'Content-Type': 'application/json'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// request the GET on config.couch and get the update_seq
|
|
33
|
+
const resp = await needle('get', config.couch, opts)
|
|
34
|
+
options.since = resp.body.update_seq
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const changes = ChangesStream(options)
|
|
38
|
+
|
|
39
|
+
changes.on('readable', () => {
|
|
40
|
+
const change = changes.read();
|
|
41
|
+
if (change.results && Array.isArray(change.results)) {
|
|
42
|
+
// emit each one seperate
|
|
43
|
+
change.results.forEach((/** @type {ChangeInfo} */ c) => emitter.emit('change', c))
|
|
44
|
+
} else emitter.emit('change', change)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Bind the provided change listener
|
|
48
|
+
emitter.on('change', onChange)
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
52
|
+
removeListener: (event, listener) => emitter.removeListener(event, listener),
|
|
53
|
+
stop: () => {
|
|
54
|
+
changes.destroy()
|
|
55
|
+
emitter.removeAllListeners()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
package/impl/query.mjs
CHANGED
|
@@ -9,28 +9,53 @@ import { createLogger } from './logger.mjs'
|
|
|
9
9
|
import pkg from 'lodash'
|
|
10
10
|
const { includes } = pkg
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* @type { z.infer<SimpleViewQuery> }
|
|
14
|
+
* @param {import('../schema/config.mjs').CouchConfigSchema} config
|
|
15
|
+
* @param {string} view
|
|
16
|
+
* @param {import('../schema/query.mjs').SimpleViewOptionsSchema} [options]
|
|
17
|
+
*/
|
|
18
|
+
export const query = SimpleViewQuery.implement(async (config, view, options = {}) => {
|
|
14
19
|
const logger = createLogger(config)
|
|
15
20
|
logger.info(`Starting view query: ${view}`)
|
|
16
21
|
logger.debug('Query options:', options)
|
|
17
22
|
|
|
18
23
|
// @ts-ignore
|
|
19
|
-
|
|
24
|
+
let qs = queryString(options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
|
|
20
25
|
logger.debug('Generated query string:', qs)
|
|
21
|
-
|
|
26
|
+
let method = 'GET'
|
|
27
|
+
let payload = null
|
|
22
28
|
const opts = {
|
|
23
29
|
json: true,
|
|
24
30
|
headers: {
|
|
25
31
|
'Content-Type': 'application/json'
|
|
26
32
|
}
|
|
27
33
|
}
|
|
34
|
+
|
|
35
|
+
// If keys are supplied, issue a POST to circumvent GET query string limits
|
|
36
|
+
// see http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
|
|
37
|
+
if (typeof options.keys !== 'undefined') {
|
|
38
|
+
const MAX_URL_LENGTH = 2000;
|
|
39
|
+
// according to http://stackoverflow.com/a/417184/680742,
|
|
40
|
+
// the de facto URL length limit is 2000 characters
|
|
41
|
+
|
|
42
|
+
const keysAsString = `keys=${encodeURIComponent(JSON.stringify(options.keys))}`;
|
|
43
|
+
if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
|
|
44
|
+
// If the keys are short enough, do a GET. we do this to work around
|
|
45
|
+
// Safari not understanding 304s on POSTs (see pouchdb/pouchdb#1239)
|
|
46
|
+
qs += (qs[0] === '?' ? '&' : '?') + keysAsString;
|
|
47
|
+
} else {
|
|
48
|
+
method = 'POST';
|
|
49
|
+
payload = {keys: options.keys};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
const url = `${config.couch}/${view}?${qs.toString()}`
|
|
29
54
|
// @ts-ignore
|
|
30
55
|
let results
|
|
31
56
|
try {
|
|
32
|
-
logger.debug(`Sending
|
|
33
|
-
results = await needle('get', url, opts)
|
|
57
|
+
logger.debug(`Sending ${method} request to: ${url}`)
|
|
58
|
+
results = (method === 'GET') ? await needle('get', url, opts) : await needle('post', url, payload, opts)
|
|
34
59
|
} catch (err) {
|
|
35
60
|
logger.error('Network error during query:', err)
|
|
36
61
|
RetryableError.handleNetworkError(err)
|
|
@@ -63,7 +88,12 @@ export const query = SimpleViewQuery.implement(async (config, view, options) =>
|
|
|
63
88
|
* @param {{ [key: string]: any }} options - The options object containing query parameters.
|
|
64
89
|
* @param {string[]} params - The list of parameter names to include in the query string.
|
|
65
90
|
*/
|
|
66
|
-
|
|
91
|
+
/**
|
|
92
|
+
* @param {{ [key: string]: any }} options
|
|
93
|
+
* @param {string[]} params
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function queryString (options = {}, params) {
|
|
67
97
|
const parts = Object.keys(options).map(key => {
|
|
68
98
|
let value = options[key]
|
|
69
99
|
if (includes(params, key)) {
|
package/impl/stream.mjs
CHANGED
|
@@ -16,8 +16,25 @@ export const queryStream = (rawConfig, view, options, onRow) => new Promise((res
|
|
|
16
16
|
|
|
17
17
|
if (!options) options = {}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
let method = 'GET'
|
|
20
|
+
let payload = null
|
|
21
|
+
let qs = queryString(options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
|
|
20
22
|
logger.debug('Generated query string:', qs)
|
|
23
|
+
|
|
24
|
+
// If keys are supplied, issue a POST to circumvent GET query string limits
|
|
25
|
+
if (typeof options.keys !== 'undefined') {
|
|
26
|
+
const MAX_URL_LENGTH = 2000
|
|
27
|
+
const keysAsString = `keys=${encodeURIComponent(JSON.stringify(options.keys))}`
|
|
28
|
+
|
|
29
|
+
if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
|
|
30
|
+
// If the keys are short enough, do a GET
|
|
31
|
+
qs += (qs[0] === '?' ? '&' : '?') + keysAsString
|
|
32
|
+
} else {
|
|
33
|
+
method = 'POST'
|
|
34
|
+
payload = { keys: options.keys }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
const url = `${config.couch}/${view}?${qs.toString()}`
|
|
22
39
|
const opts = {
|
|
23
40
|
json: true,
|
|
@@ -53,7 +70,9 @@ export const queryStream = (rawConfig, view, options, onRow) => new Promise((res
|
|
|
53
70
|
resolve(undefined) // all work should be done in the stream
|
|
54
71
|
})
|
|
55
72
|
|
|
56
|
-
const req =
|
|
73
|
+
const req = method === 'GET'
|
|
74
|
+
? needle.get(url, opts)
|
|
75
|
+
: needle.post(url, payload, opts)
|
|
57
76
|
|
|
58
77
|
req.on('response', response => {
|
|
59
78
|
logger.debug(`Received response with status code: ${response.statusCode}`)
|
|
@@ -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}]`, nonRetryErr)
|
|
89
|
+
emitter.emit('error', nonRetryErr)
|
|
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
|
}
|