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.
- package/README.md +89 -28
- package/dist/cjs/index.cjs +888 -443
- package/dist/esm/index.mjs +883 -443
- package/eslint.config.js +6 -1
- package/impl/bindConfig.mts +30 -3
- package/impl/bulkGet.mts +50 -27
- package/impl/bulkRemove.mts +4 -2
- package/impl/bulkSave.mts +50 -28
- package/impl/get.mts +49 -40
- package/impl/getDBInfo.mts +26 -24
- package/impl/patch.mts +46 -42
- package/impl/put.mts +39 -21
- package/impl/query.mts +101 -81
- package/impl/remove.mts +33 -33
- package/impl/stream.mts +163 -102
- package/impl/sugar/watch.mts +165 -97
- package/impl/utils/errors.mts +261 -35
- package/impl/utils/fetch.mts +201 -0
- package/impl/utils/parseRows.mts +47 -6
- package/impl/utils/request.mts +22 -0
- package/impl/utils/response.mts +50 -0
- package/impl/utils/transactionErrors.mts +14 -8
- package/impl/utils/url.mts +21 -0
- package/index.mts +19 -2
- package/migration_guides/v7.md +353 -0
- package/package.json +4 -4
- package/schema/config.mts +17 -34
- package/schema/request.mts +36 -0
- package/schema/sugar/watch.mts +1 -1
- package/tsconfig.json +9 -1
- package/types/output/impl/bindConfig.d.mts +31 -149
- package/types/output/impl/bindConfig.d.mts.map +1 -1
- package/types/output/impl/bindConfig.test.d.mts +2 -0
- package/types/output/impl/bindConfig.test.d.mts.map +1 -0
- package/types/output/impl/bulkGet.d.mts +5 -5
- package/types/output/impl/bulkGet.d.mts.map +1 -1
- package/types/output/impl/bulkRemove.d.mts +4 -2
- package/types/output/impl/bulkRemove.d.mts.map +1 -1
- package/types/output/impl/bulkSave.d.mts +2 -2
- package/types/output/impl/bulkSave.d.mts.map +1 -1
- package/types/output/impl/get.d.mts +2 -2
- package/types/output/impl/get.d.mts.map +1 -1
- package/types/output/impl/getDBInfo.d.mts +1 -1
- package/types/output/impl/getDBInfo.d.mts.map +1 -1
- package/types/output/impl/patch.d.mts +8 -3
- package/types/output/impl/patch.d.mts.map +1 -1
- package/types/output/impl/put.d.mts.map +1 -1
- package/types/output/impl/query.d.mts +8 -23
- package/types/output/impl/query.d.mts.map +1 -1
- package/types/output/impl/remove.d.mts.map +1 -1
- package/types/output/impl/request-controls.test.d.mts +2 -0
- package/types/output/impl/request-controls.test.d.mts.map +1 -0
- package/types/output/impl/stream.d.mts +1 -1
- package/types/output/impl/stream.d.mts.map +1 -1
- package/types/output/impl/sugar/watch.d.mts +7 -5
- package/types/output/impl/sugar/watch.d.mts.map +1 -1
- package/types/output/impl/utils/errors.d.mts +84 -26
- package/types/output/impl/utils/errors.d.mts.map +1 -1
- package/types/output/impl/utils/fetch.d.mts +27 -0
- package/types/output/impl/utils/fetch.d.mts.map +1 -0
- package/types/output/impl/utils/fetch.test.d.mts +2 -0
- package/types/output/impl/utils/fetch.test.d.mts.map +1 -0
- package/types/output/impl/utils/parseRows.d.mts +3 -0
- package/types/output/impl/utils/parseRows.d.mts.map +1 -1
- package/types/output/impl/utils/request.d.mts +6 -0
- package/types/output/impl/utils/request.d.mts.map +1 -0
- package/types/output/impl/utils/response.d.mts +7 -0
- package/types/output/impl/utils/response.d.mts.map +1 -0
- package/types/output/impl/utils/response.test.d.mts +2 -0
- package/types/output/impl/utils/response.test.d.mts.map +1 -0
- package/types/output/impl/utils/trackedEmitter.test.d.mts +2 -0
- package/types/output/impl/utils/trackedEmitter.test.d.mts.map +1 -0
- package/types/output/impl/utils/transactionErrors.d.mts +5 -4
- package/types/output/impl/utils/transactionErrors.d.mts.map +1 -1
- package/types/output/impl/utils/transactionErrors.test.d.mts +2 -0
- package/types/output/impl/utils/transactionErrors.test.d.mts.map +1 -0
- package/types/output/impl/utils/url.d.mts +4 -0
- package/types/output/impl/utils/url.d.mts.map +1 -0
- package/types/output/impl/utils/url.test.d.mts +2 -0
- package/types/output/impl/utils/url.test.d.mts.map +1 -0
- package/types/output/index.d.mts +5 -2
- package/types/output/index.d.mts.map +1 -1
- package/types/output/schema/config.d.mts +13 -69
- package/types/output/schema/config.d.mts.map +1 -1
- package/types/output/schema/config.test.d.mts +2 -0
- package/types/output/schema/config.test.d.mts.map +1 -0
- package/types/output/schema/request.d.mts +10 -0
- package/types/output/schema/request.d.mts.map +1 -0
- package/types/output/schema/sugar/lock.test.d.mts +2 -0
- package/types/output/schema/sugar/lock.test.d.mts.map +1 -0
- package/types/output/schema/sugar/watch.d.mts +1 -1
- package/types/output/schema/sugar/watch.d.mts.map +1 -1
- package/types/output/schema/sugar/watch.test.d.mts +2 -0
- package/types/output/schema/sugar/watch.test.d.mts.map +1 -0
- package/impl/utils/mergeNeedleOpts.mts +0 -16
- package/schema/util.mts +0 -8
- package/types/output/impl/utils/mergeNeedleOpts.d.mts +0 -53
- package/types/output/impl/utils/mergeNeedleOpts.d.mts.map +0 -1
- package/types/output/schema/util.d.mts +0 -85
- package/types/output/schema/util.d.mts.map +0 -1
package/impl/stream.mts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
}
|
package/impl/sugar/watch.mts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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)
|
|
47
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
178
|
-
removeListener: (event: string, listener:
|
|
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
|
}
|