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/patch.mts CHANGED
@@ -4,6 +4,7 @@ import { createLogger } from './utils/logger.mts'
4
4
  import { setTimeout } from 'node:timers/promises'
5
5
  import { CouchConfig, type CouchConfigInput } from '../schema/config.mts'
6
6
  import { z } from 'zod'
7
+ import { ConflictError, HideABedError, OperationError, RetryableError } from './utils/errors.mts'
7
8
 
8
9
  const PatchProperties = z
9
10
  .looseObject({
@@ -20,7 +21,10 @@ const PatchProperties = z
20
21
  * @param _properties - Properties to merge into the document (must include _rev)
21
22
  * @returns The result of the put operation
22
23
  *
23
- * @throws Error if the _rev does not match or other errors occur
24
+ * @throws {ConflictError} When the supplied `_rev` does not match the current document revision.
25
+ * @throws {NotFoundError} When the document does not exist.
26
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs while reading or saving.
27
+ * @throws {OperationError} When a non-retryable operational failure occurs.
24
28
  */
25
29
  export const patch = async (
26
30
  configInput: CouchConfigInput,
@@ -33,13 +37,15 @@ export const patch = async (
33
37
 
34
38
  logger.info(`Starting patch operation for document ${id}`)
35
39
  logger.debug('Patch properties:', properties)
36
- const doc = await get(config, id)
37
- if (doc?._rev !== properties._rev) {
38
- return {
39
- statusCode: 409,
40
- ok: false,
41
- error: 'conflict'
42
- }
40
+ const doc = await get({ ...config, throwOnGetNotFound: true }, id)
41
+ if (!doc) {
42
+ throw new OperationError('Patch failed', {
43
+ docId: id,
44
+ operation: 'patch'
45
+ })
46
+ }
47
+ if (doc._rev !== properties._rev) {
48
+ throw new ConflictError(id, { operation: 'patch' })
43
49
  }
44
50
 
45
51
  const updatedDoc = { ...doc, ...properties }
@@ -60,7 +66,9 @@ export const patch = async (
60
66
  * @param properties - Properties to merge into the document
61
67
  * @returns The result of the put operation or an error if max retries are exceeded
62
68
  *
63
- * @throws Error if max retries are exceeded or other errors occur
69
+ * @throws {NotFoundError} When the document does not exist.
70
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs before retries are exhausted.
71
+ * @throws {OperationError} When retries are exhausted or a non-retryable operational failure occurs.
64
72
  */
65
73
  export const patchDangerously = async (
66
74
  configInput: CouchConfigInput,
@@ -75,60 +83,56 @@ export const patchDangerously = async (
75
83
 
76
84
  logger.info(`Starting patch operation for document ${id}`)
77
85
  logger.debug('Patch properties:', properties)
86
+ let lastError: unknown
78
87
 
79
88
  while (attempts <= maxRetries) {
80
89
  logger.debug(`Attempt ${attempts + 1} of ${maxRetries + 1}`)
81
90
  try {
82
- const doc = await get(config, id)
91
+ const doc = await get({ ...config, throwOnGetNotFound: true }, id)
83
92
  if (!doc) {
84
- logger.warn(`Document ${id} not found`)
85
- return { ok: false, statusCode: 404, error: 'not_found' }
93
+ throw new OperationError('Patch failed', {
94
+ docId: id,
95
+ operation: 'patchDangerously'
96
+ })
86
97
  }
87
-
88
98
  const updatedDoc = { ...doc, ...properties }
89
99
  logger.debug('Merged document:', updatedDoc)
90
100
 
91
101
  const result = await put(config, updatedDoc)
92
-
93
- // Check if the response indicates a conflict
94
- if (result.ok) {
95
- logger.info(`Successfully patched document ${id}, rev: ${result.rev}`)
96
- return result
97
- }
98
-
99
- // If not ok, treat as conflict and retry
100
- attempts++
101
- if (attempts > maxRetries) {
102
- logger.error(`Failed to patch ${id} after ${maxRetries} attempts`)
103
- throw new Error(`Failed to patch after ${maxRetries} attempts`)
102
+ logger.info(`Successfully patched document ${id}, rev: ${result.rev}`)
103
+ return result
104
+ } catch (err) {
105
+ if (!(err instanceof Error)) {
106
+ throw err
104
107
  }
105
108
 
106
- logger.warn(`Conflict detected for ${id}, retrying (attempt ${attempts})`)
107
- await setTimeout(delay)
108
- delay *= config.backoffFactor || 2
109
- logger.debug(`Next retry delay: ${delay}ms`)
110
- } catch (err) {
111
- if (
112
- typeof err === 'object' &&
113
- err !== null &&
114
- 'message' in err &&
115
- err.message === 'not_found'
116
- ) {
117
- logger.warn(`Document ${id} not found during patch operation`)
118
- return { ok: false, statusCode: 404, error: 'not_found' }
109
+ if (!(err instanceof ConflictError) && !(err instanceof RetryableError)) {
110
+ throw err
119
111
  }
120
112
 
121
- // Handle other errors (network, etc)
113
+ lastError = err
122
114
  attempts++
123
115
  if (attempts > maxRetries) {
124
- const error = `Failed to patch after ${maxRetries} attempts: ${err}`
125
- logger.error(error)
126
- return { ok: false, statusCode: 500, error }
116
+ logger.error(`Failed to patch ${id} after ${maxRetries} attempts`, err)
117
+ throw new OperationError('Patch failed', {
118
+ cause: err,
119
+ couchError: err instanceof HideABedError ? err.couchError : undefined,
120
+ docId: id,
121
+ operation: 'patchDangerously',
122
+ statusCode: err instanceof HideABedError ? err.statusCode : undefined
123
+ })
127
124
  }
128
125
 
129
126
  logger.warn(`Error during patch attempt ${attempts}: ${err}`)
130
127
  await setTimeout(delay)
128
+ delay *= config.backoffFactor || 2
131
129
  logger.debug(`Retrying after ${delay}ms`)
132
130
  }
133
131
  }
132
+
133
+ throw new OperationError('Patch failed', {
134
+ cause: lastError,
135
+ docId: id,
136
+ operation: 'patchDangerously'
137
+ })
134
138
  }
package/impl/put.mts CHANGED
@@ -1,10 +1,18 @@
1
- import needle from 'needle'
2
1
  import { createLogger } from './utils/logger.mts'
3
- import { mergeNeedleOpts } from './utils/mergeNeedleOpts.mts'
4
- import { RetryableError } from './utils/errors.mts'
2
+ import { ConflictError, RetryableError, createResponseError } from './utils/errors.mts'
5
3
  import { CouchConfig, type CouchConfigInput } from '../schema/config.mts'
6
4
  import { CouchPutResponse, type CouchDoc } from '../schema/couch/couch.output.schema.ts'
7
5
  import { z } from 'zod'
6
+ import { fetchCouchJson } from './utils/fetch.mts'
7
+ import { isRecord, isSuccessStatusCode } from './utils/response.mts'
8
+ import { createCouchDocUrl } from './utils/url.mts'
9
+
10
+ type CouchMutationBody = {
11
+ error?: string
12
+ ok?: boolean
13
+ reason?: string
14
+ statusCode?: number
15
+ } & Record<string, unknown>
8
16
 
9
17
  export const put = async (
10
18
  configInput: CouchConfigInput,
@@ -12,43 +20,53 @@ export const put = async (
12
20
  ): Promise<z.infer<typeof CouchPutResponse>> => {
13
21
  const config = CouchConfig.parse(configInput)
14
22
  const logger = createLogger(config)
15
- const url = `${config.couch}/${doc._id}`
23
+ const url = createCouchDocUrl(doc._id, config.couch)
16
24
  const body = doc
17
- const opts = {
18
- json: true,
19
- headers: {
20
- 'Content-Type': 'application/json'
21
- }
22
- }
23
- const mergedOpts = mergeNeedleOpts(config, opts)
24
25
 
25
26
  logger.info(`Putting document with id: ${doc._id}`)
26
27
  let resp
27
28
  try {
28
- resp = await needle('put', url, body, mergedOpts)
29
+ resp = await fetchCouchJson({
30
+ auth: config.auth,
31
+ method: 'PUT',
32
+ operation: 'put',
33
+ request: config.request,
34
+ url,
35
+ body
36
+ })
29
37
  } catch (err) {
30
38
  logger.error('Error during put operation:', err)
31
- RetryableError.handleNetworkError(err)
39
+ RetryableError.handleNetworkError(err, 'put')
32
40
  }
33
41
 
34
42
  if (!resp) {
35
43
  logger.error('No response received from put request')
36
- throw new RetryableError('no response', 503)
44
+ throw new RetryableError('Put failed', 503, { operation: 'put' })
37
45
  }
38
46
 
39
- const result = resp?.body || {}
47
+ const result: CouchMutationBody = {
48
+ ...(isRecord(resp.body) ? resp.body : {})
49
+ }
40
50
  result.statusCode = resp.statusCode
41
51
 
42
52
  if (resp.statusCode === 409) {
43
53
  logger.warn(`Conflict detected for document: ${doc._id}`)
44
- result.ok = false
45
- result.error = 'conflict'
46
- return CouchPutResponse.parse(result)
54
+ throw new ConflictError(doc._id, {
55
+ couchError: typeof result.error === 'string' ? result.error : undefined,
56
+ operation: 'put',
57
+ statusCode: resp.statusCode
58
+ })
47
59
  }
48
60
 
49
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
50
- logger.warn(`Retryable status code received: ${resp.statusCode}`)
51
- throw new RetryableError(result.reason || 'retryable error', resp.statusCode)
61
+ if (!isSuccessStatusCode('documentWrite', resp.statusCode) || !result.ok) {
62
+ logger.error(`Unexpected status code: ${resp.statusCode}`)
63
+ throw createResponseError({
64
+ body: resp.body,
65
+ defaultMessage: 'Put failed',
66
+ docId: doc._id,
67
+ operation: 'put',
68
+ statusCode: resp.statusCode
69
+ })
52
70
  }
53
71
 
54
72
  logger.info(`Successfully saved document: ${doc._id}`)
package/impl/query.mts CHANGED
@@ -1,15 +1,57 @@
1
- import needle, { type BodyData, type NeedleHttpVerbs } from 'needle'
2
1
  import { createLogger } from './utils/logger.mts'
3
2
 
4
3
  import { CouchConfig, type CouchConfigInput } from '../schema/config.mts'
5
4
  import { z, ZodAny, ZodNever } from 'zod'
6
5
  import { queryString } from './utils/queryString.mts'
7
- import { mergeNeedleOpts } from './utils/mergeNeedleOpts.mts'
8
- import { RetryableError } from './utils/errors.mts'
6
+ import { RetryableError, createResponseError } from './utils/errors.mts'
9
7
  import { ViewOptions, type ViewString } from '../schema/couch/couch.input.schema.ts'
10
8
  import type { CouchDoc, ViewQueryResponseValidated } from '../schema/couch/couch.output.schema.ts'
11
9
  import type { StandardSchemaV1 } from '../types/standard-schema.ts'
12
10
  import { parseRows, type OnInvalidDocAction } from './utils/parseRows.mts'
11
+ import { fetchCouchJson } from './utils/fetch.mts'
12
+ import { isSuccessStatusCode } from './utils/response.mts'
13
+ import { createCouchPathUrl } from './utils/url.mts'
14
+
15
+ type QueryBody = {
16
+ error?: string
17
+ reason?: string
18
+ rows?: unknown[]
19
+ } & Record<string, unknown>
20
+
21
+ const ValidSchema = z.custom(
22
+ value => {
23
+ return value !== null && typeof value === 'object' && '~standard' in value
24
+ },
25
+ {
26
+ message: 'schema must be a valid StandardSchemaV1 schema'
27
+ }
28
+ )
29
+
30
+ const QueryValidationSchema = z
31
+ .object({
32
+ docSchema: ValidSchema.optional(),
33
+ keySchema: ValidSchema.optional(),
34
+ onInvalidDoc: z.enum(['skip', 'throw']).optional(),
35
+ valueSchema: ValidSchema.optional()
36
+ })
37
+ .optional()
38
+
39
+ const QueryOptionsSchema = ViewOptions.extend({
40
+ validate: QueryValidationSchema
41
+ }).strict()
42
+
43
+ type QueryRequestOptions<
44
+ DocSchema extends StandardSchemaV1,
45
+ KeySchema extends StandardSchemaV1,
46
+ ValueSchema extends StandardSchemaV1
47
+ > = ViewOptions & {
48
+ validate?: {
49
+ onInvalidDoc?: OnInvalidDocAction
50
+ docSchema?: DocSchema
51
+ keySchema?: KeySchema
52
+ valueSchema?: ValueSchema
53
+ }
54
+ }
13
55
 
14
56
  export async function query<
15
57
  DocSchema extends StandardSchemaV1 = typeof CouchDoc,
@@ -18,14 +60,8 @@ export async function query<
18
60
  >(
19
61
  config: CouchConfigInput,
20
62
  view: ViewString,
21
- options: ViewOptions & {
63
+ options: QueryRequestOptions<DocSchema, KeySchema, ValueSchema> & {
22
64
  include_docs: true
23
- validate?: {
24
- onInvalidDoc?: OnInvalidDocAction
25
- docSchema?: DocSchema
26
- keySchema?: KeySchema
27
- valueSchema?: ValueSchema
28
- }
29
65
  }
30
66
  ): Promise<ViewQueryResponseValidated<DocSchema, KeySchema, ValueSchema>>
31
67
 
@@ -36,14 +72,8 @@ export async function query<
36
72
  >(
37
73
  config: CouchConfigInput,
38
74
  view: ViewString,
39
- options: ViewOptions & {
75
+ options: QueryRequestOptions<DocSchema, KeySchema, ValueSchema> & {
40
76
  include_docs?: false | undefined
41
- validate?: {
42
- onInvalidDoc?: OnInvalidDocAction
43
- docSchema?: DocSchema
44
- keySchema?: KeySchema
45
- valueSchema?: ValueSchema
46
- }
47
77
  }
48
78
  ): Promise<ViewQueryResponseValidated<ZodNever, KeySchema, ValueSchema>>
49
79
 
@@ -72,8 +102,8 @@ export async function query(
72
102
  * @returns The parsed view response with rows validated against the supplied schemas.
73
103
  *
74
104
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
75
- * @throws {Error<Array<StandardSchemaV1.Issue>>} When the configuration or validation schemas fail to parse.
76
- * @throws {Error} When CouchDB returns a non-retryable error payload.
105
+ * @throws {ValidationError} When row, key, value, or included document validation fails.
106
+ * @throws {OperationError} When CouchDB returns a non-retryable response or malformed row payload.
77
107
  */
78
108
  export async function query<
79
109
  DocSchema extends StandardSchemaV1,
@@ -82,19 +112,13 @@ export async function query<
82
112
  >(
83
113
  _config: CouchConfigInput,
84
114
  view: ViewString,
85
- options: ViewOptions & {
86
- validate?: {
87
- onInvalidDoc?: OnInvalidDocAction
88
- docSchema?: DocSchema
89
- keySchema?: KeySchema
90
- valueSchema?: ValueSchema
91
- }
92
- } = {}
115
+ options: QueryRequestOptions<DocSchema, KeySchema, ValueSchema> = {}
93
116
  ): Promise<ViewQueryResponseValidated<DocSchema, KeySchema, ValueSchema>> {
94
117
  const configParseResult = CouchConfig.safeParse(_config)
118
+ const parsedOptions = QueryOptionsSchema.parse(options || {})
95
119
  const logger = createLogger(_config)
96
120
  logger.info(`Starting view query: ${view}`)
97
- logger.debug('Query options:', ViewOptions.parse(options || {}))
121
+ logger.debug('Query options:', parsedOptions)
98
122
  if (!configParseResult.success) {
99
123
  logger.error(`Invalid configuration provided: ${z.prettifyError(configParseResult.error)}`)
100
124
  throw configParseResult.error
@@ -102,86 +126,94 @@ export async function query<
102
126
 
103
127
  const config = configParseResult.data
104
128
 
105
- let qs = queryString(options)
106
- let method: NeedleHttpVerbs = 'get'
107
- let payload: BodyData = null
108
-
109
- const opts = {
110
- json: true,
111
- headers: {
112
- 'Content-Type': 'application/json'
113
- }
114
- }
115
-
116
- const mergedOpts = mergeNeedleOpts(config, opts)
129
+ let qs = queryString(parsedOptions)
130
+ let method: 'GET' | 'POST' = 'GET'
131
+ let payload: Record<string, unknown> | null = null
117
132
 
118
133
  // If keys are supplied, issue a POST to circumvent GET query string limits
119
134
  // see http://wiki.apache.org/couchdb/HTTP_view_API#Querying_Options
120
- if (typeof options.keys !== 'undefined') {
135
+ if (typeof parsedOptions.keys !== 'undefined') {
121
136
  const MAX_URL_LENGTH = 2000
122
137
  // according to http://stackoverflow.com/a/417184/680742,
123
138
  // the de facto URL length limit is 2000 characters
124
139
 
125
- const _options = structuredClone(options)
126
- delete _options.keys
127
- qs = queryString(_options)
140
+ const { keys, validate, ...queryableOptions } = parsedOptions
141
+ qs = queryString(queryableOptions)
128
142
 
129
- const keysAsString = `keys=${JSON.stringify(options.keys)}`
143
+ const keysAsString = `keys=${JSON.stringify(keys)}`
130
144
 
131
145
  if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
132
146
  // If the keys are short enough, do a GET. we do this to work around
133
147
  // Safari not understanding 304s on POSTs (see pouchdb/pouchdb#1239)
134
- method = 'get'
148
+ method = 'GET'
135
149
  if (qs.length > 0) qs += '&'
136
150
  else qs = ''
137
151
  qs += keysAsString
138
152
  } else {
139
- method = 'post'
140
- payload = { keys: options.keys }
153
+ method = 'POST'
154
+ payload = { keys: parsedOptions.keys }
141
155
  }
142
156
  }
143
157
 
144
158
  logger.debug('Generated query string:', qs)
145
- const url = `${config.couch}/${view}?${qs}`
159
+ const url = createCouchPathUrl(view, config.couch)
160
+ if (qs) url.search = qs
146
161
  let results
147
162
 
148
163
  try {
149
164
  logger.debug(`Sending ${method} request to: ${url}`)
150
- results =
151
- method === 'get'
152
- ? await needle('get', url, mergedOpts)
153
- : await needle('post', url, payload, mergedOpts)
165
+ results = await fetchCouchJson<QueryBody>({
166
+ auth: config.auth,
167
+ method,
168
+ operation: 'query',
169
+ request: config.request,
170
+ url,
171
+ body: method === 'POST' ? payload : undefined
172
+ })
154
173
  } catch (err) {
155
174
  logger.error('Network error during query:', err)
156
- RetryableError.handleNetworkError(err)
175
+ RetryableError.handleNetworkError(err, 'query')
157
176
  }
158
177
 
159
178
  if (!results) {
160
179
  logger.error('No response received from query request')
161
- throw new RetryableError('no response', 503)
180
+ throw new RetryableError('Query failed', 503, { operation: 'query' })
162
181
  }
163
182
 
164
183
  const body = results.body
165
184
 
166
- if (RetryableError.isRetryableStatusCode(results.statusCode)) {
167
- logger.warn(`Retryable status code received: ${results.statusCode}`)
168
- throw new RetryableError(body.error || 'retryable error during query', results.statusCode)
169
- }
185
+ if (!isSuccessStatusCode('viewQuery', results.statusCode) || body.error) {
186
+ if (body.error) {
187
+ logger.error(`Query error: ${JSON.stringify(body)}`)
188
+ } else {
189
+ logger.error(`Unexpected status code: ${results.statusCode}`)
190
+ }
170
191
 
171
- if (body.error) {
172
- logger.error(`Query error: ${JSON.stringify(body)}`)
173
- throw new Error(`CouchDB query error: ${body.error} - ${body.reason || ''}`)
192
+ throw createResponseError({
193
+ body,
194
+ defaultMessage: 'Query failed',
195
+ operation: 'query',
196
+ statusCode: results.statusCode
197
+ })
174
198
  }
175
199
 
176
200
  // If validation schemas are provided, validate each row accordingly
177
- if (options.validate && body.rows) {
178
- body.rows = await parseRows<DocSchema, KeySchema, ValueSchema>(body.rows, options.validate)
179
- }
201
+ const rows: ViewQueryResponseValidated<DocSchema, KeySchema, ValueSchema>['rows'] =
202
+ options.validate && body.rows
203
+ ? await parseRows<DocSchema, KeySchema, ValueSchema>(body.rows, {
204
+ ...options.validate,
205
+ defaultMessage: 'Query failed',
206
+ operation: 'query'
207
+ })
208
+ : ((body.rows ?? []) as ViewQueryResponseValidated<DocSchema, KeySchema, ValueSchema>['rows'])
180
209
 
181
210
  logger.info(`Successfully executed view query: ${view}`)
182
- logger.debug('Query response:', body)
211
+ logger.debug('Query response:', { ...body, rows })
183
212
 
184
- return body
213
+ return {
214
+ ...body,
215
+ rows
216
+ }
185
217
  }
186
218
 
187
219
  export type QueryBound = {
@@ -191,14 +223,8 @@ export type QueryBound = {
191
223
  ValueSchema extends StandardSchemaV1 = ZodAny
192
224
  >(
193
225
  view: ViewString,
194
- options: ViewOptions & {
226
+ options: QueryRequestOptions<DocSchema, KeySchema, ValueSchema> & {
195
227
  include_docs: true
196
- validate?: {
197
- onInvalidDoc?: OnInvalidDocAction
198
- docSchema?: DocSchema
199
- keySchema?: KeySchema
200
- valueSchema?: ValueSchema
201
- }
202
228
  }
203
229
  ): Promise<ViewQueryResponseValidated<DocSchema, KeySchema, ValueSchema>>
204
230
  <
@@ -207,14 +233,8 @@ export type QueryBound = {
207
233
  ValueSchema extends StandardSchemaV1 = ZodAny
208
234
  >(
209
235
  view: ViewString,
210
- options: ViewOptions & {
236
+ options: QueryRequestOptions<DocSchema, KeySchema, ValueSchema> & {
211
237
  include_docs?: false | undefined
212
- validate?: {
213
- onInvalidDoc?: OnInvalidDocAction
214
- docSchema?: DocSchema
215
- keySchema?: KeySchema
216
- valueSchema?: ValueSchema
217
- }
218
238
  }
219
239
  ): Promise<ViewQueryResponseValidated<ZodNever, KeySchema, ValueSchema>>
220
240
  (
package/impl/remove.mts CHANGED
@@ -1,63 +1,63 @@
1
- import needle from 'needle'
2
1
  import { createLogger } from './utils/logger.mts'
3
- import { mergeNeedleOpts } from './utils/mergeNeedleOpts.mts'
4
- import { RetryableError } from './utils/errors.mts'
2
+ import { NotFoundError, RetryableError, createResponseError } from './utils/errors.mts'
5
3
  import { CouchPutResponse } from '../schema/couch/couch.output.schema.ts'
6
4
  import { CouchConfig, type CouchConfigInput } from '../schema/config.mts'
5
+ import { fetchCouchJson } from './utils/fetch.mts'
6
+ import { isRecord, isSuccessStatusCode } from './utils/response.mts'
7
+ import { createCouchDocUrl } from './utils/url.mts'
8
+
9
+ type CouchMutationBody = {
10
+ error?: string
11
+ ok?: boolean
12
+ reason?: string
13
+ statusCode?: number
14
+ } & Record<string, unknown>
7
15
 
8
16
  export const remove = async (configInput: CouchConfigInput, id: string, rev: string) => {
9
17
  const config = CouchConfig.parse(configInput)
10
18
  const logger = createLogger(config)
11
- const url = `${config.couch}/${id}?rev=${rev}`
12
- const opts = {
13
- json: true,
14
- headers: {
15
- 'Content-Type': 'application/json'
16
- }
17
- }
18
- const mergedOpts = mergeNeedleOpts(config, opts)
19
+ const url = createCouchDocUrl(id, config.couch)
20
+ url.searchParams.set('rev', rev)
19
21
 
20
22
  logger.info(`Deleting document with id: ${id}`)
21
23
  let resp
22
24
  try {
23
- resp = await needle('delete', url, null, mergedOpts)
25
+ resp = await fetchCouchJson({
26
+ auth: config.auth,
27
+ method: 'DELETE',
28
+ operation: 'remove',
29
+ request: config.request,
30
+ url
31
+ })
24
32
  } catch (err) {
25
33
  logger.error('Error during delete operation:', err)
26
- RetryableError.handleNetworkError(err)
34
+ RetryableError.handleNetworkError(err, 'remove')
27
35
  }
28
36
 
29
37
  if (!resp) {
30
38
  logger.error('No response received from delete request')
31
- throw new RetryableError('no response', 503)
39
+ throw new RetryableError('Remove failed', 503, { operation: 'remove' })
32
40
  }
33
41
 
34
- let result
35
- if (typeof resp.body === 'string') {
36
- try {
37
- result = JSON.parse(resp.body)
38
- } catch {
39
- result = {}
40
- }
41
- } else {
42
- result = resp.body || {}
42
+ const result: CouchMutationBody = {
43
+ ...(isRecord(resp.body) ? resp.body : {})
43
44
  }
44
45
  result.statusCode = resp.statusCode
45
46
 
46
47
  if (resp.statusCode === 404) {
47
48
  logger.warn(`Document not found for deletion: ${id}`)
48
- result.ok = false
49
- result.error = 'not_found'
50
- return CouchPutResponse.parse(result)
51
- }
52
-
53
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
54
- logger.warn(`Retryable status code received: ${resp.statusCode}`)
55
- throw new RetryableError(result.reason || 'retryable error', resp.statusCode)
49
+ throw new NotFoundError(id, { operation: 'remove', statusCode: resp.statusCode })
56
50
  }
57
51
 
58
- if (resp.statusCode !== 200) {
52
+ if (!isSuccessStatusCode('documentDelete', resp.statusCode) || !result.ok) {
59
53
  logger.error(`Unexpected status code: ${resp.statusCode}`)
60
- throw new Error(result.reason || 'failed')
54
+ throw createResponseError({
55
+ body: resp.body,
56
+ defaultMessage: 'Remove failed',
57
+ docId: id,
58
+ operation: 'remove',
59
+ statusCode: resp.statusCode
60
+ })
61
61
  }
62
62
 
63
63
  logger.info(`Successfully deleted document: ${id}`)