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.
Files changed (75) hide show
  1. package/README.md +160 -2
  2. package/dualmode.config.json +11 -0
  3. package/impl/changes.mjs +58 -0
  4. package/impl/query.mjs +37 -7
  5. package/impl/stream.mjs +21 -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/package.json +6 -5
  13. package/schema/bind.mjs +8 -1
  14. package/schema/changes.mjs +59 -0
  15. package/schema/query.mjs +1 -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 -98
  25. package/cjs/impl/queryBuilder.cjs +0 -99
  26. package/cjs/impl/retry.cjs +0 -54
  27. package/cjs/impl/stream.cjs +0 -109
  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 -61
  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 -182
  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 -810
  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 -397
  73. package/schema/query.d.mts.map +0 -1
  74. package/schema/stream.d.mts +0 -205
  75. 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
- // results: [
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
- // docs: [
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
 
@@ -0,0 +1,11 @@
1
+ {
2
+ "esbuild": {
3
+ "entryPoints": {
4
+ "ignore": [
5
+ "./node_modules/**/*",
6
+ "./tests/**",
7
+ "./temp/**"
8
+ ]
9
+ }
10
+ }
11
+ }
@@ -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
- /** @type { z.infer<SimpleViewQuery> } query */
13
- export const query = SimpleViewQuery.implement(async (config, view, options) => {
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
- const qs = queryString(options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
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 GET request to: ${url}`)
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
- export function queryString (options, params) {
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
- const qs = queryString(options, ['key', 'startkey', 'endkey', 'reduce', 'group', 'group_level', 'stale', 'limit'])
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 = needle.get(url, opts)
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
  }