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.
Files changed (75) hide show
  1. package/README.md +175 -14
  2. package/impl/changes.mjs +53 -0
  3. package/impl/errors.mjs +1 -1
  4. package/impl/query.mjs +14 -6
  5. package/impl/stream.mjs +2 -2
  6. package/impl/sugar/lock.mjs +70 -0
  7. package/impl/sugar/watch.mjs +154 -0
  8. package/index.mjs +23 -3
  9. package/integration/changes.mjs +60 -0
  10. package/integration/disconnect-watch.mjs +36 -0
  11. package/integration/watch.mjs +40 -0
  12. package/log.txt +84 -0
  13. package/package.json +3 -2
  14. package/schema/bind.mjs +8 -1
  15. package/schema/changes.mjs +59 -0
  16. package/schema/sugar/lock.mjs +50 -0
  17. package/schema/sugar/watch.mjs +29 -0
  18. package/types/changes-stream.d.ts +11 -0
  19. package/cjs/impl/bulk.cjs +0 -267
  20. package/cjs/impl/crud.cjs +0 -121
  21. package/cjs/impl/errors.cjs +0 -75
  22. package/cjs/impl/logger.cjs +0 -70
  23. package/cjs/impl/patch.cjs +0 -95
  24. package/cjs/impl/query.cjs +0 -110
  25. package/cjs/impl/queryBuilder.cjs +0 -99
  26. package/cjs/impl/retry.cjs +0 -54
  27. package/cjs/impl/stream.cjs +0 -121
  28. package/cjs/impl/trackedEmitter.cjs +0 -54
  29. package/cjs/impl/transactionErrors.cjs +0 -70
  30. package/cjs/index.cjs +0 -95
  31. package/cjs/schema/bind.cjs +0 -44
  32. package/cjs/schema/bulk.cjs +0 -88
  33. package/cjs/schema/config.cjs +0 -48
  34. package/cjs/schema/crud.cjs +0 -77
  35. package/cjs/schema/patch.cjs +0 -53
  36. package/cjs/schema/query.cjs +0 -62
  37. package/cjs/schema/stream.cjs +0 -42
  38. package/impl/bulk.d.mts +0 -11
  39. package/impl/bulk.d.mts.map +0 -1
  40. package/impl/crud.d.mts +0 -7
  41. package/impl/crud.d.mts.map +0 -1
  42. package/impl/errors.d.mts +0 -43
  43. package/impl/errors.d.mts.map +0 -1
  44. package/impl/logger.d.mts +0 -32
  45. package/impl/logger.d.mts.map +0 -1
  46. package/impl/patch.d.mts +0 -6
  47. package/impl/patch.d.mts.map +0 -1
  48. package/impl/query.d.mts +0 -195
  49. package/impl/query.d.mts.map +0 -1
  50. package/impl/queryBuilder.d.mts +0 -94
  51. package/impl/queryBuilder.d.mts.map +0 -1
  52. package/impl/retry.d.mts +0 -2
  53. package/impl/retry.d.mts.map +0 -1
  54. package/impl/stream.d.mts +0 -3
  55. package/impl/stream.d.mts.map +0 -1
  56. package/impl/trackedEmitter.d.mts +0 -8
  57. package/impl/trackedEmitter.d.mts.map +0 -1
  58. package/impl/transactionErrors.d.mts +0 -57
  59. package/impl/transactionErrors.d.mts.map +0 -1
  60. package/index.d.mts +0 -56
  61. package/index.d.mts.map +0 -1
  62. package/schema/bind.d.mts +0 -820
  63. package/schema/bind.d.mts.map +0 -1
  64. package/schema/bulk.d.mts +0 -910
  65. package/schema/bulk.d.mts.map +0 -1
  66. package/schema/config.d.mts +0 -79
  67. package/schema/config.d.mts.map +0 -1
  68. package/schema/crud.d.mts +0 -491
  69. package/schema/crud.d.mts.map +0 -1
  70. package/schema/patch.d.mts +0 -255
  71. package/schema/patch.d.mts.map +0 -1
  72. package/schema/query.d.mts +0 -406
  73. package/schema/query.d.mts.map +0 -1
  74. package/schema/stream.d.mts +0 -211
  75. 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. Found and notFound documents are separated. Both properties are records of id to result. This makes it easy to deal with the results.
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
-
@@ -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 keysAsString = `keys=${encodeURIComponent(JSON.stringify(options.keys))}`;
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
- qs += (qs[0] === '?' ? '&' : '?') + keysAsString;
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
+ })