hide-a-bed 6.0.0 → 7.0.0-beta.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 (100) hide show
  1. package/README.md +89 -28
  2. package/dist/cjs/index.cjs +888 -443
  3. package/dist/esm/index.mjs +883 -443
  4. package/eslint.config.js +6 -1
  5. package/impl/bindConfig.mts +30 -3
  6. package/impl/bulkGet.mts +50 -27
  7. package/impl/bulkRemove.mts +4 -2
  8. package/impl/bulkSave.mts +50 -28
  9. package/impl/get.mts +49 -40
  10. package/impl/getDBInfo.mts +26 -24
  11. package/impl/patch.mts +46 -42
  12. package/impl/put.mts +39 -21
  13. package/impl/query.mts +101 -81
  14. package/impl/remove.mts +33 -33
  15. package/impl/stream.mts +163 -102
  16. package/impl/sugar/watch.mts +165 -97
  17. package/impl/utils/errors.mts +261 -35
  18. package/impl/utils/fetch.mts +201 -0
  19. package/impl/utils/parseRows.mts +47 -6
  20. package/impl/utils/request.mts +22 -0
  21. package/impl/utils/response.mts +50 -0
  22. package/impl/utils/transactionErrors.mts +14 -8
  23. package/impl/utils/url.mts +21 -0
  24. package/index.mts +19 -2
  25. package/migration_guides/v7.md +353 -0
  26. package/package.json +4 -4
  27. package/schema/config.mts +17 -34
  28. package/schema/request.mts +36 -0
  29. package/schema/sugar/watch.mts +1 -1
  30. package/tsconfig.json +9 -1
  31. package/types/output/impl/bindConfig.d.mts +31 -149
  32. package/types/output/impl/bindConfig.d.mts.map +1 -1
  33. package/types/output/impl/bindConfig.test.d.mts +2 -0
  34. package/types/output/impl/bindConfig.test.d.mts.map +1 -0
  35. package/types/output/impl/bulkGet.d.mts +5 -5
  36. package/types/output/impl/bulkGet.d.mts.map +1 -1
  37. package/types/output/impl/bulkRemove.d.mts +4 -2
  38. package/types/output/impl/bulkRemove.d.mts.map +1 -1
  39. package/types/output/impl/bulkSave.d.mts +2 -2
  40. package/types/output/impl/bulkSave.d.mts.map +1 -1
  41. package/types/output/impl/get.d.mts +2 -2
  42. package/types/output/impl/get.d.mts.map +1 -1
  43. package/types/output/impl/getDBInfo.d.mts +1 -1
  44. package/types/output/impl/getDBInfo.d.mts.map +1 -1
  45. package/types/output/impl/patch.d.mts +8 -3
  46. package/types/output/impl/patch.d.mts.map +1 -1
  47. package/types/output/impl/put.d.mts.map +1 -1
  48. package/types/output/impl/query.d.mts +8 -23
  49. package/types/output/impl/query.d.mts.map +1 -1
  50. package/types/output/impl/remove.d.mts.map +1 -1
  51. package/types/output/impl/request-controls.test.d.mts +2 -0
  52. package/types/output/impl/request-controls.test.d.mts.map +1 -0
  53. package/types/output/impl/stream.d.mts +1 -1
  54. package/types/output/impl/stream.d.mts.map +1 -1
  55. package/types/output/impl/sugar/watch.d.mts +7 -5
  56. package/types/output/impl/sugar/watch.d.mts.map +1 -1
  57. package/types/output/impl/utils/errors.d.mts +84 -26
  58. package/types/output/impl/utils/errors.d.mts.map +1 -1
  59. package/types/output/impl/utils/fetch.d.mts +27 -0
  60. package/types/output/impl/utils/fetch.d.mts.map +1 -0
  61. package/types/output/impl/utils/fetch.test.d.mts +2 -0
  62. package/types/output/impl/utils/fetch.test.d.mts.map +1 -0
  63. package/types/output/impl/utils/parseRows.d.mts +3 -0
  64. package/types/output/impl/utils/parseRows.d.mts.map +1 -1
  65. package/types/output/impl/utils/request.d.mts +6 -0
  66. package/types/output/impl/utils/request.d.mts.map +1 -0
  67. package/types/output/impl/utils/response.d.mts +7 -0
  68. package/types/output/impl/utils/response.d.mts.map +1 -0
  69. package/types/output/impl/utils/response.test.d.mts +2 -0
  70. package/types/output/impl/utils/response.test.d.mts.map +1 -0
  71. package/types/output/impl/utils/trackedEmitter.test.d.mts +2 -0
  72. package/types/output/impl/utils/trackedEmitter.test.d.mts.map +1 -0
  73. package/types/output/impl/utils/transactionErrors.d.mts +5 -4
  74. package/types/output/impl/utils/transactionErrors.d.mts.map +1 -1
  75. package/types/output/impl/utils/transactionErrors.test.d.mts +2 -0
  76. package/types/output/impl/utils/transactionErrors.test.d.mts.map +1 -0
  77. package/types/output/impl/utils/url.d.mts +4 -0
  78. package/types/output/impl/utils/url.d.mts.map +1 -0
  79. package/types/output/impl/utils/url.test.d.mts +2 -0
  80. package/types/output/impl/utils/url.test.d.mts.map +1 -0
  81. package/types/output/index.d.mts +5 -2
  82. package/types/output/index.d.mts.map +1 -1
  83. package/types/output/schema/config.d.mts +13 -69
  84. package/types/output/schema/config.d.mts.map +1 -1
  85. package/types/output/schema/config.test.d.mts +2 -0
  86. package/types/output/schema/config.test.d.mts.map +1 -0
  87. package/types/output/schema/request.d.mts +10 -0
  88. package/types/output/schema/request.d.mts.map +1 -0
  89. package/types/output/schema/sugar/lock.test.d.mts +2 -0
  90. package/types/output/schema/sugar/lock.test.d.mts.map +1 -0
  91. package/types/output/schema/sugar/watch.d.mts +1 -1
  92. package/types/output/schema/sugar/watch.d.mts.map +1 -1
  93. package/types/output/schema/sugar/watch.test.d.mts +2 -0
  94. package/types/output/schema/sugar/watch.test.d.mts.map +1 -0
  95. package/impl/utils/mergeNeedleOpts.mts +0 -16
  96. package/schema/util.mts +0 -8
  97. package/types/output/impl/utils/mergeNeedleOpts.d.mts +0 -53
  98. package/types/output/impl/utils/mergeNeedleOpts.d.mts.map +0 -1
  99. package/types/output/schema/util.d.mts +0 -85
  100. package/types/output/schema/util.d.mts.map +0 -1
package/impl/stream.mts CHANGED
@@ -1,16 +1,18 @@
1
- import needle from 'needle'
2
- import type { IncomingMessage } from 'node:http'
1
+ import { Readable } from 'node:stream'
3
2
  import Chain from 'stream-chain'
4
3
  import Parser from 'stream-json/Parser.js'
5
4
  import Pick from 'stream-json/filters/Pick.js'
6
5
  import StreamArray from 'stream-json/streamers/StreamArray.js'
7
6
  import { CouchConfig, type CouchConfigInput } from '../schema/config.mts'
8
- import { RetryableError } from './utils/errors.mts'
7
+ import { OperationError, RetryableError, createResponseError } from './utils/errors.mts'
9
8
  import { createLogger } from './utils/logger.mts'
10
9
  import { queryString } from './utils/queryString.mts'
11
- import { mergeNeedleOpts } from './utils/mergeNeedleOpts.mts'
12
10
  import type { ViewRow } from '../schema/couch/couch.output.schema.ts'
13
- import type { ViewOptions } from '../schema/couch/couch.input.schema.ts'
11
+ import { ViewOptions } from '../schema/couch/couch.input.schema.ts'
12
+ import { fetchCouchStream } from './utils/fetch.mts'
13
+ import type { ReadableStream } from 'node:stream/web'
14
+ import { isSuccessStatusCode } from './utils/response.mts'
15
+ import { createCouchPathUrl } from './utils/url.mts'
14
16
 
15
17
  type StreamArrayChunk<Row> = {
16
18
  key: number
@@ -34,110 +36,169 @@ export async function queryStream(
34
36
  onRow: OnRow
35
37
  ): Promise<void> {
36
38
  return new Promise((resolve, reject) => {
37
- const config = CouchConfig.parse(rawConfig)
38
- const logger = createLogger(config)
39
- logger.info(`Starting view query stream: ${view}`)
40
- logger.debug('Query options:', options)
41
-
42
- const queryOptions: ViewOptions = options ?? {}
43
-
44
- let method: HttpMethod = 'GET'
45
- let payload: Record<string, unknown> | null = null
46
- let qs = queryString(queryOptions)
47
- logger.debug('Generated query string:', qs)
48
-
49
- if (typeof queryOptions.keys !== 'undefined') {
50
- const MAX_URL_LENGTH = 2000
51
- const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`
52
-
53
- if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
54
- qs += (qs.length > 0 ? '&' : '') + keysAsString
55
- } else {
56
- method = 'POST'
57
- payload = { keys: queryOptions.keys }
39
+ void (async () => {
40
+ const config = CouchConfig.parse(rawConfig)
41
+ const logger = createLogger(config)
42
+ logger.info(`Starting view query stream: ${view}`)
43
+ const queryOptions = ViewOptions.parse(options ?? {})
44
+ const request = config.request
45
+ logger.debug('Query options:', { ...queryOptions, request })
46
+
47
+ let method: HttpMethod = 'GET'
48
+ let payload: Record<string, unknown> | null = null
49
+ let qs = queryString(queryOptions)
50
+ logger.debug('Generated query string:', qs)
51
+
52
+ if (typeof queryOptions.keys !== 'undefined') {
53
+ const MAX_URL_LENGTH = 2000
54
+ const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`
55
+
56
+ if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
57
+ qs += (qs.length > 0 ? '&' : '') + keysAsString
58
+ } else {
59
+ method = 'POST'
60
+ payload = { keys: queryOptions.keys }
61
+ }
58
62
  }
59
- }
60
63
 
61
- const url = `${config.couch}/${view}?${qs}`
62
- const opts = {
63
- json: true,
64
- headers: {
64
+ const url = createCouchPathUrl(view, config.couch)
65
+ if (qs) url.search = qs
66
+
67
+ const requestHeaders = {
65
68
  'Content-Type': 'application/json'
66
- },
67
- parse_response: false as const
68
- }
69
- const mergedOpts = mergeNeedleOpts(config, opts)
70
-
71
- const parserPipeline = Chain.chain([
72
- new Parser(),
73
- new Pick({ filter: 'rows' }),
74
- new StreamArray()
75
- ])
76
-
77
- let rowCount = 0
78
- let settled = false
79
-
80
- const settleReject = (err: Error) => {
81
- if (settled) return
82
- settled = true
83
- reject(err)
84
- }
85
-
86
- const settleResolve = () => {
87
- if (settled) return
88
- settled = true
89
- resolve()
90
- }
91
-
92
- let request: ReturnType<typeof needle.get> | ReturnType<typeof needle.post> | null = null
93
-
94
- parserPipeline.on('data', (chunk: StreamArrayChunk<ViewRow>) => {
95
- try {
96
- rowCount++
97
- onRow(chunk.value)
98
- } catch (callbackErr) {
99
- const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr))
100
- parserPipeline.destroy(error)
101
- settleReject(error)
102
69
  }
103
- })
104
-
105
- parserPipeline.on('error', (err: Error) => {
106
- logger.error('Stream parsing error:', err)
107
- parserPipeline.destroy()
108
- settleReject(new Error(`Stream parsing error: ${err.message}`, { cause: err }))
109
- })
110
-
111
- parserPipeline.on('end', () => {
112
- logger.info(`Stream completed, processed ${rowCount} rows`)
113
- settleResolve()
114
- })
115
-
116
- request = method === 'GET' ? needle.get(url, mergedOpts) : needle.post(url, payload, mergedOpts)
117
-
118
- request.on('response', (response: IncomingMessage) => {
119
- logger.debug(`Received response with status code: ${response.statusCode}`)
120
- if (RetryableError.isRetryableStatusCode(response.statusCode)) {
121
- logger.warn(`Retryable status code received: ${response.statusCode}`)
122
- settleReject(new RetryableError('retryable error during stream query', response.statusCode))
123
- // @ts-expect-error bad type?
124
- request.destroy()
70
+ const abortController = new AbortController()
71
+ const requestAbortHandler = () => {
72
+ const reason =
73
+ request?.signal?.reason instanceof Error
74
+ ? request.signal.reason
75
+ : new DOMException('The operation was aborted.', 'AbortError')
76
+ abortController.abort(reason)
77
+ responseStream?.destroy(reason)
78
+ parserPipeline.destroy(reason)
79
+ settleReject(reason)
125
80
  }
126
- })
127
81
 
128
- request.on('error', (err: NodeJS.ErrnoException) => {
129
- logger.error('Network error during stream query:', err)
130
- parserPipeline.destroy(err)
131
- try {
132
- RetryableError.handleNetworkError(err)
133
- } catch (retryErr) {
134
- settleReject(retryErr as Error)
135
- return
136
- } finally {
137
- settleReject(err)
82
+ const parserPipeline = Chain.chain([
83
+ new Parser(),
84
+ new Pick({ filter: 'rows' }),
85
+ new StreamArray()
86
+ ])
87
+
88
+ let rowCount = 0
89
+ let settled = false
90
+
91
+ const settleReject = (err: unknown) => {
92
+ if (settled) return
93
+ settled = true
94
+ request?.signal?.removeEventListener('abort', requestAbortHandler)
95
+ reject(err)
138
96
  }
139
- })
140
97
 
141
- request.pipe(parserPipeline)
98
+ const settleResolve = () => {
99
+ if (settled) return
100
+ settled = true
101
+ request?.signal?.removeEventListener('abort', requestAbortHandler)
102
+ resolve()
103
+ }
104
+
105
+ let responseStream: Readable | null = null
106
+
107
+ request?.signal?.addEventListener('abort', requestAbortHandler, { once: true })
108
+
109
+ parserPipeline.on('data', (chunk: StreamArrayChunk<ViewRow>) => {
110
+ try {
111
+ rowCount++
112
+ onRow(chunk.value)
113
+ } catch (callbackErr) {
114
+ const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr))
115
+ parserPipeline.destroy(error)
116
+ settleReject(error)
117
+ }
118
+ })
119
+
120
+ parserPipeline.on('error', (err: Error) => {
121
+ logger.error('Stream parsing error:', err)
122
+ parserPipeline.destroy()
123
+ settleReject(
124
+ new OperationError('Stream parsing failed', {
125
+ cause: err,
126
+ operation: 'queryStream'
127
+ })
128
+ )
129
+ })
130
+
131
+ parserPipeline.on('end', () => {
132
+ logger.info(`Stream completed, processed ${rowCount} rows`)
133
+ settleResolve()
134
+ })
135
+
136
+ try {
137
+ const response = await fetchCouchStream({
138
+ auth: config.auth,
139
+ method,
140
+ operation: 'queryStream',
141
+ url,
142
+ body: method === 'POST' ? payload : undefined,
143
+ headers: requestHeaders,
144
+ request,
145
+ signal: abortController.signal
146
+ })
147
+
148
+ logger.debug(`Received response with status code: ${response.statusCode}`)
149
+
150
+ if (RetryableError.isRetryableStatusCode(response.statusCode)) {
151
+ logger.warn(`Retryable status code received: ${response.statusCode}`)
152
+ abortController.abort()
153
+ settleReject(
154
+ new RetryableError('Stream query failed', response.statusCode, {
155
+ operation: 'queryStream'
156
+ })
157
+ )
158
+ return
159
+ }
160
+
161
+ if (!isSuccessStatusCode('viewStream', response.statusCode)) {
162
+ abortController.abort()
163
+ settleReject(
164
+ createResponseError({
165
+ defaultMessage: 'Stream query failed',
166
+ operation: 'queryStream',
167
+ statusCode: response.statusCode
168
+ })
169
+ )
170
+ return
171
+ }
172
+
173
+ if (!response.body) {
174
+ settleReject(new RetryableError('Stream query failed', 503, { operation: 'queryStream' }))
175
+ return
176
+ }
177
+
178
+ responseStream = Readable.fromWeb(response.body as unknown as ReadableStream)
179
+
180
+ responseStream.on('error', err => {
181
+ logger.error('Network error during stream query:', err)
182
+ parserPipeline.destroy(err as Error)
183
+ try {
184
+ RetryableError.handleNetworkError(err, 'queryStream')
185
+ } catch (retryErr) {
186
+ settleReject(retryErr)
187
+ return
188
+ }
189
+ })
190
+
191
+ responseStream.pipe(parserPipeline)
192
+ } catch (err) {
193
+ logger.error('Network error during stream query:', err)
194
+ parserPipeline.destroy(err as Error)
195
+ try {
196
+ RetryableError.handleNetworkError(err, 'queryStream')
197
+ } catch (retryErr) {
198
+ settleReject(retryErr)
199
+ return
200
+ }
201
+ }
202
+ })()
142
203
  })
143
204
  }
@@ -1,15 +1,20 @@
1
- import needle from 'needle'
2
1
  import { EventEmitter } from 'events'
3
- import { RetryableError } from '../utils/errors.mts'
2
+ import { OperationError, RetryableError, createResponseError } from '../utils/errors.mts'
4
3
  import { createLogger } from '../utils/logger.mts'
5
4
  import { WatchOptions, type WatchOptionsInput } from '../../schema/sugar/watch.mts'
6
- import { mergeNeedleOpts } from '../utils/mergeNeedleOpts.mts'
7
5
  import { setTimeout } from 'node:timers/promises'
8
- import {
9
- CouchConfig,
10
- type CouchConfigInput,
11
- type NeedleBaseOptionsSchema
12
- } from '../../schema/config.mts'
6
+ import { CouchConfig, type CouchConfigInput } from '../../schema/config.mts'
7
+ import { fetchCouchStream } from '../utils/fetch.mts'
8
+ import { isSuccessStatusCode } from '../utils/response.mts'
9
+ import { createCouchPathUrl } from '../utils/url.mts'
10
+
11
+ export type WatchListener = (...args: Array<unknown>) => void
12
+
13
+ export type WatchHandle = {
14
+ on: (event: string, listener: WatchListener) => EventEmitter
15
+ removeListener: (event: string, listener: WatchListener) => EventEmitter
16
+ stop: () => void
17
+ }
13
18
 
14
19
  /**
15
20
  * Watch for changes to specified document IDs in CouchDB.
@@ -29,126 +34,187 @@ export function watchDocs(
29
34
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
35
  onChange: (change: any) => void,
31
36
  optionsInput: WatchOptionsInput = {}
32
- ) {
37
+ ): WatchHandle {
33
38
  const config = CouchConfig.parse(configInput)
34
39
  const options = WatchOptions.parse(optionsInput)
35
40
  const logger = createLogger(config)
36
41
  const emitter = new EventEmitter()
42
+ const request = config.request
37
43
  let lastSeq: null | 'now' = null
38
44
  let stopping = false
45
+ let stopEndEmitted = false
39
46
  let retryCount = 0
40
- let currentRequest: null | ReturnType<typeof needle.get> = null
47
+ const lifecycleAbortController = new AbortController()
48
+ let currentAbortController: AbortController | null = null
41
49
  const maxRetries = options.maxRetries || 10
42
50
  const initialDelay = options.initialDelay || 1000
43
51
  const maxDelay = options.maxDelay || 30000
44
52
 
45
53
  const _docIds = Array.isArray(docIds) ? docIds : [docIds]
46
- if (_docIds.length === 0) throw new Error('docIds must be a non-empty array')
47
- if (_docIds.length > 100) throw new Error('docIds must be an array of 100 or fewer elements')
54
+ if (_docIds.length === 0) {
55
+ throw new OperationError('docIds must be a non-empty array', {
56
+ operation: 'watchDocs'
57
+ })
58
+ }
59
+ if (_docIds.length > 100) {
60
+ throw new OperationError('docIds must be an array of 100 or fewer elements', {
61
+ operation: 'watchDocs'
62
+ })
63
+ }
64
+
65
+ const emitStopEnd = () => {
66
+ if (stopEndEmitted) return
67
+ stopEndEmitted = true
68
+ emitter.emit('end', { lastSeq })
69
+ }
70
+
71
+ const stopWatching = () => {
72
+ if (stopping) return
73
+ stopping = true
74
+ lifecycleAbortController.abort()
75
+ currentAbortController?.abort()
76
+ request?.signal?.removeEventListener('abort', handleExternalAbort)
77
+ emitStopEnd()
78
+ emitter.removeAllListeners()
79
+ }
80
+
81
+ const handleExternalAbort = () => {
82
+ logger.info(`Request signal aborted, stopping watcher for [${_docIds}]`)
83
+ stopWatching()
84
+ }
85
+
86
+ request?.signal?.addEventListener('abort', handleExternalAbort, { once: true })
48
87
 
49
88
  const connect = async () => {
50
89
  if (stopping) return
51
90
 
52
91
  const feed = 'continuous'
53
92
  const includeDocs = options.include_docs ?? false
54
- const ids = _docIds.join('","')
55
- const url = `${config.couch}/_changes?feed=${feed}&since=${lastSeq}&include_docs=${includeDocs}&filter=_doc_ids&doc_ids=["${ids}"]`
56
-
57
- const opts: NeedleBaseOptionsSchema = {
58
- json: false,
59
- headers: {
60
- 'Content-Type': 'application/json'
61
- },
62
- parse_response: false
63
- }
64
- const mergedOpts = mergeNeedleOpts(config, opts)
93
+ const url = createCouchPathUrl('_changes', config.couch)
94
+ url.searchParams.set('feed', feed)
95
+ url.searchParams.set('since', String(lastSeq))
96
+ url.searchParams.set('include_docs', String(includeDocs))
97
+ url.searchParams.set('filter', '_doc_ids')
98
+ url.searchParams.set('doc_ids', JSON.stringify(_docIds))
99
+ const abortController = new AbortController()
100
+ currentAbortController = abortController
65
101
 
66
102
  let buffer = ''
67
- currentRequest = needle.get(url, mergedOpts)
68
-
69
- currentRequest.on('data', chunk => {
70
- buffer += chunk.toString()
71
- const lines = buffer.split('\n')
72
-
73
- // Keep the last partial line in the buffer
74
- buffer = lines.pop() || ''
75
-
76
- // Process complete lines
77
- for (const line of lines) {
78
- if (line.trim()) {
79
- try {
80
- const change = JSON.parse(line)
81
- if (!change.id) return null // ignore just last_seq
82
- logger.debug(`Change detected, watching [${_docIds}]`, change)
83
- lastSeq = change.seq || change.last_seq
84
- emitter.emit('change', change)
85
- } catch (err) {
86
- logger.error('Error parsing change:', err, 'Line:', line)
87
- }
88
- }
103
+ const processLine = (line: string) => {
104
+ if (!line.trim()) return
105
+
106
+ try {
107
+ const change = JSON.parse(line)
108
+ if (!change.id) return
109
+ logger.debug(`Change detected, watching [${_docIds}]`, change)
110
+ lastSeq = change.seq || change.last_seq
111
+ emitter.emit('change', change)
112
+ } catch (err) {
113
+ logger.error('Error parsing change:', err, 'Line:', line)
89
114
  }
90
- })
115
+ }
116
+
117
+ try {
118
+ const response = await fetchCouchStream({
119
+ auth: config.auth,
120
+ method: 'GET',
121
+ operation: 'watchDocs',
122
+ url,
123
+ headers: {
124
+ 'Content-Type': 'application/json'
125
+ },
126
+ request,
127
+ signal: AbortSignal.any([abortController.signal, lifecycleAbortController.signal])
128
+ })
91
129
 
92
- currentRequest.on('response', response => {
93
130
  logger.debug(
94
131
  `Received response with status code, watching [${_docIds}]: ${response.statusCode}`
95
132
  )
96
133
  if (RetryableError.isRetryableStatusCode(response.statusCode)) {
97
134
  logger.warn(`Retryable status code received: ${response.statusCode}`)
98
- // @ts-expect-error bad type?
99
- currentRequest?.destroy()
100
- handleReconnect()
101
- } else {
102
- // Reset retry count on successful connection
103
- retryCount = 0
135
+ abortController.abort()
136
+ await handleReconnect()
137
+ return
104
138
  }
105
- })
106
139
 
107
- currentRequest.on('error', async err => {
108
- if (stopping) {
109
- logger.info('stopping in progress, ignore stream error')
140
+ if (!isSuccessStatusCode('changesFeed', response.statusCode)) {
141
+ emitter.emit(
142
+ 'error',
143
+ createResponseError({
144
+ defaultMessage: 'Watch request failed',
145
+ operation: 'watchDocs',
146
+ statusCode: response.statusCode
147
+ })
148
+ )
110
149
  return
111
150
  }
112
- logger.error(`Network error during stream, watching [${_docIds}]:`, err.toString())
113
- try {
114
- RetryableError.handleNetworkError(err)
115
- } catch (filteredError) {
116
- if (filteredError instanceof RetryableError) {
117
- logger.info(`Retryable error, watching [${_docIds}]:`, filteredError.toString())
118
- handleReconnect()
119
- } else {
120
- logger.error(`Non-retryable error, watching [${_docIds}]`, filteredError?.toString())
121
- emitter.emit('error', filteredError)
122
- }
151
+
152
+ retryCount = 0
153
+
154
+ if (!response.body) {
155
+ throw new RetryableError('Watch request failed', 503, { operation: 'watchDocs' })
123
156
  }
124
- })
125
157
 
126
- currentRequest.on('end', () => {
127
- // Process any remaining data in buffer
158
+ const reader = response.body.getReader()
159
+ const decoder = new TextDecoder()
160
+
161
+ while (!stopping) {
162
+ const { done, value } = await reader.read()
163
+ if (done) break
164
+
165
+ buffer += decoder.decode(value, { stream: true })
166
+ const lines = buffer.split('\n')
167
+ buffer = lines.pop() || ''
168
+ lines.forEach(processLine)
169
+ }
170
+
171
+ if (stopping || abortController.signal.aborted) {
172
+ return
173
+ }
174
+
175
+ buffer += decoder.decode()
176
+
128
177
  if (buffer.trim()) {
129
- try {
130
- const change = JSON.parse(buffer)
131
- logger.debug('Final change detected:', change)
132
- emitter.emit('change', change)
133
- } catch (err) {
134
- logger.error('Error parsing final change:', err)
135
- }
178
+ processLine(buffer)
136
179
  }
180
+
137
181
  logger.info('Stream completed. Last seen seq: ', lastSeq)
138
182
  emitter.emit('end', { lastSeq })
139
183
 
140
- // If the stream ends and we're not stopping, attempt to reconnect
141
184
  if (!stopping) {
142
- handleReconnect()
185
+ await handleReconnect()
143
186
  }
144
- })
187
+ } catch (err) {
188
+ if (stopping || abortController.signal.aborted) {
189
+ logger.info('stopping in progress, ignore stream error')
190
+ return
191
+ }
192
+
193
+ logger.error(`Network error during stream, watching [${_docIds}]:`, String(err))
194
+ try {
195
+ RetryableError.handleNetworkError(err, 'watchDocs')
196
+ } catch (filteredError) {
197
+ if (filteredError instanceof RetryableError) {
198
+ logger.info(`Retryable error, watching [${_docIds}]:`, filteredError.toString())
199
+ await handleReconnect()
200
+ } else {
201
+ logger.error(`Non-retryable error, watching [${_docIds}]`, String(filteredError))
202
+ emitter.emit('error', filteredError)
203
+ }
204
+ }
205
+ }
145
206
  }
146
207
 
147
208
  const handleReconnect = async () => {
148
209
  if (stopping || retryCount >= maxRetries) {
149
210
  if (retryCount >= maxRetries) {
150
211
  logger.error(`Max retries (${maxRetries}) reached, giving up`)
151
- emitter.emit('error', new Error('Max retries reached'))
212
+ emitter.emit(
213
+ 'error',
214
+ new OperationError('Watch retries exhausted', {
215
+ operation: 'watchDocs'
216
+ })
217
+ )
152
218
  }
153
219
  return
154
220
  }
@@ -157,32 +223,34 @@ export function watchDocs(
157
223
  retryCount++
158
224
 
159
225
  logger.info(`Attempting to reconnect in ${delay}ms (attempt ${retryCount} of ${maxRetries})`)
160
- await setTimeout(delay)
226
+ try {
227
+ await setTimeout(delay, undefined, { signal: lifecycleAbortController.signal })
228
+ } catch {
229
+ return
230
+ }
161
231
 
162
232
  try {
163
- connect()
233
+ await connect()
164
234
  } catch (err) {
165
235
  logger.error('Error during reconnection:', err)
166
- handleReconnect()
236
+ await handleReconnect()
167
237
  }
168
238
  }
169
239
 
170
- // Start initial connection
171
- connect()
172
-
173
240
  // Bind the provided change listener
174
241
  emitter.on('change', onChange)
175
242
 
243
+ // Start initial connection
244
+ if (request?.signal?.aborted) {
245
+ stopWatching()
246
+ } else {
247
+ void connect()
248
+ }
249
+
176
250
  return {
177
- on: (event: string, listener: EventListener) => emitter.on(event, listener),
178
- removeListener: (event: string, listener: EventListener) =>
251
+ on: (event: string, listener: WatchListener) => emitter.on(event, listener),
252
+ removeListener: (event: string, listener: WatchListener) =>
179
253
  emitter.removeListener(event, listener),
180
- stop: () => {
181
- stopping = true
182
- // @ts-expect-error bad type?
183
- if (currentRequest) currentRequest.destroy()
184
- emitter.emit('end', { lastSeq })
185
- emitter.removeAllListeners()
186
- }
254
+ stop: stopWatching
187
255
  }
188
256
  }