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
@@ -0,0 +1,50 @@
1
+ export const isRecord = (value: unknown): value is Record<string, unknown> => {
2
+ return value !== null && typeof value === 'object'
3
+ }
4
+
5
+ export type CouchSuccessProfile =
6
+ | 'bulkGet'
7
+ | 'bulkSave'
8
+ | 'changesFeed'
9
+ | 'database'
10
+ | 'documentDelete'
11
+ | 'documentRead'
12
+ | 'documentWrite'
13
+ | 'viewQuery'
14
+ | 'viewStream'
15
+
16
+ const SUCCESS_STATUS_CODES: Record<CouchSuccessProfile, readonly number[]> = {
17
+ bulkGet: [200],
18
+ bulkSave: [201, 202],
19
+ changesFeed: [200],
20
+ database: [200],
21
+ documentDelete: [200, 202],
22
+ documentRead: [200],
23
+ documentWrite: [200, 201, 202],
24
+ viewQuery: [200],
25
+ viewStream: [200]
26
+ }
27
+
28
+ export const getReason = (value: unknown, fallback: string) => {
29
+ if (!isRecord(value) || typeof value.reason !== 'string') {
30
+ return fallback
31
+ }
32
+
33
+ return value.reason
34
+ }
35
+
36
+ export const getCouchError = (value: unknown) => {
37
+ if (!isRecord(value) || typeof value.error !== 'string') {
38
+ return undefined
39
+ }
40
+
41
+ return value.error
42
+ }
43
+
44
+ export const getSuccessStatusCodes = (profile: CouchSuccessProfile) => {
45
+ return SUCCESS_STATUS_CODES[profile]
46
+ }
47
+
48
+ export const isSuccessStatusCode = (profile: CouchSuccessProfile, statusCode: number) => {
49
+ return SUCCESS_STATUS_CODES[profile].includes(statusCode)
50
+ }
@@ -1,24 +1,28 @@
1
- export class TransactionSetupError extends Error {
1
+ import { OperationError } from './errors.mts'
2
+
3
+ export class TransactionSetupError extends OperationError {
2
4
  details: Record<string, unknown>
3
5
 
4
6
  constructor(message: string, details: Record<string, unknown> = {}) {
5
- super(message)
7
+ super(message, { category: 'transaction' })
6
8
  this.name = 'TransactionSetupError'
7
9
  this.details = details
8
10
  }
9
11
  }
10
12
 
11
- export class TransactionVersionConflictError extends Error {
13
+ export class TransactionVersionConflictError extends OperationError {
12
14
  conflictingIds: string[]
13
15
 
14
16
  constructor(conflictingIds: string[]) {
15
- super(`Revision mismatch for documents: ${conflictingIds.join(', ')}`)
17
+ super(`Revision mismatch for documents: ${conflictingIds.join(', ')}`, {
18
+ category: 'transaction'
19
+ })
16
20
  this.name = 'TransactionVersionConflictError'
17
21
  this.conflictingIds = conflictingIds
18
22
  }
19
23
  }
20
24
 
21
- export class TransactionBulkOperationError extends Error {
25
+ export class TransactionBulkOperationError extends OperationError {
22
26
  failedDocs: {
23
27
  ok?: boolean | null
24
28
  id?: string | null
@@ -36,13 +40,15 @@ export class TransactionBulkOperationError extends Error {
36
40
  reason?: string | null
37
41
  }>
38
42
  ) {
39
- super(`Failed to save documents: ${failedDocs.map(d => d.id).join(', ')}`)
43
+ super(`Failed to save documents: ${failedDocs.map(d => d.id).join(', ')}`, {
44
+ category: 'transaction'
45
+ })
40
46
  this.name = 'TransactionBulkOperationError'
41
47
  this.failedDocs = failedDocs
42
48
  }
43
49
  }
44
50
 
45
- export class TransactionRollbackError extends Error {
51
+ export class TransactionRollbackError extends OperationError {
46
52
  originalError: Error
47
53
  rollbackResults: {
48
54
  ok?: boolean | null
@@ -63,7 +69,7 @@ export class TransactionRollbackError extends Error {
63
69
  reason?: string | null
64
70
  }>
65
71
  ) {
66
- super(message)
72
+ super(message, { category: 'transaction' })
67
73
  this.name = 'TransactionRollbackError'
68
74
  this.originalError = originalError
69
75
  this.rollbackResults = rollbackResults
@@ -0,0 +1,21 @@
1
+ const ensureDirectoryUrl = (value: string | URL) => {
2
+ const url = new URL(value)
3
+
4
+ if (!url.pathname.endsWith('/')) {
5
+ url.pathname = `${url.pathname}/`
6
+ }
7
+
8
+ return url
9
+ }
10
+
11
+ export const createCouchDbUrl = (value: string | URL) => {
12
+ return new URL(value)
13
+ }
14
+
15
+ export const createCouchPathUrl = (path: string, base: string | URL) => {
16
+ return new URL(path, ensureDirectoryUrl(base))
17
+ }
18
+
19
+ export const createCouchDocUrl = (docId: string, base: string | URL) => {
20
+ return new URL(encodeURIComponent(docId), ensureDirectoryUrl(base))
21
+ }
package/index.mts CHANGED
@@ -46,6 +46,15 @@ export {
46
46
  removeLock
47
47
  }
48
48
 
49
+ export {
50
+ ConflictError,
51
+ HideABedError,
52
+ NotFoundError,
53
+ OperationError,
54
+ RetryableError,
55
+ ValidationError
56
+ } from './impl/utils/errors.mts'
57
+
49
58
  export type {
50
59
  BulkGetBound,
51
60
  BulkGetDictionaryBound,
@@ -70,10 +79,18 @@ export type {
70
79
  ViewRowValidated
71
80
  } from './schema/couch/couch.output.schema.ts'
72
81
  export type { RetryOptions } from './impl/retry.mts'
73
- export type { NetworkError, RetryableError, NotFoundError } from './impl/utils/errors.mts'
82
+ export type {
83
+ ErrorCategory,
84
+ ErrorOperation,
85
+ HideABedErrorOptions,
86
+ NetworkError,
87
+ ValidationErrorOptions
88
+ } from './impl/utils/errors.mts'
74
89
  export type { OnRow } from './impl/stream.mts'
75
- export type { CouchConfig, CouchConfigInput } from './schema/config.mts'
90
+ export type { CouchAuth, CouchAuthInput, CouchConfig, CouchConfigInput } from './schema/config.mts'
91
+ export type { Dispatcher, RequestOptions, RequestOptionsInput } from './schema/request.mts'
76
92
  export type { LockOptions, LockOptionsInput, LockDoc } from './schema/sugar/lock.mts'
93
+ export type { WatchHandle, WatchListener } from './impl/sugar/watch.mts'
77
94
  export type {
78
95
  WatchOptions as WatchOptionsSchema,
79
96
  WatchOptionsInput
@@ -0,0 +1,353 @@
1
+ # hide-a-bed v7 Migration Guide
2
+
3
+ ## When to read this
4
+
5
+ - Teams upgrading from any `hide-a-bed` v6 release to the current v7 beta line.
6
+ - Consumers who passed transport-specific `needle` options into config.
7
+ - Consumers who used return payloads like `{ ok: false, error: 'conflict' }` for control flow.
8
+ - Teams that validate docs or rows and need to update error handling.
9
+
10
+ ## Scope
11
+
12
+ This guide covers the behavior changes between the `6.0.0` release and the current v7 work in this branch. The biggest shift is the move from `needle` to native `fetch`, but the upgrade also changes config validation, request controls, and several failure paths.
13
+
14
+ ## Upgrade checklist
15
+
16
+ 1. Update `hide-a-bed` to v7 and run your tests on a Node runtime that matches the package manifest for `client/`.
17
+ 2. Remove every use of `config.needleOpts`.
18
+ 3. Move CouchDB credentials out of the Couch URL and into `config.auth`.
19
+ 4. Move timeout, abort, and dispatcher settings into `config.request`.
20
+ 5. Update any code that inspects `{ ok: false, error: ... }` from single-document helpers to catch typed errors instead.
21
+ 6. Update any validation error handling that expected raw schema issues instead of a `ValidationError`.
22
+ 7. Re-test streaming and watch flows with abort/cancellation, especially if you previously relied on request-library behavior.
23
+
24
+ ## Breaking changes
25
+
26
+ ### 1. `needleOpts` is gone
27
+
28
+ v6 accepted transport-specific options through `config.needleOpts`. v7 validates config strictly and rejects `needleOpts`.
29
+
30
+ Before:
31
+
32
+ ```ts
33
+ const config = {
34
+ couch: 'http://localhost:5984/mydb',
35
+ needleOpts: {
36
+ timeout: 5000,
37
+ username: process.env.COUCHDB_USER,
38
+ password: process.env.COUCHDB_PASSWORD
39
+ }
40
+ }
41
+ ```
42
+
43
+ After:
44
+
45
+ ```ts
46
+ const config = {
47
+ couch: 'http://localhost:5984/mydb',
48
+ auth: {
49
+ username: process.env.COUCHDB_USER!,
50
+ password: process.env.COUCHDB_PASSWORD!
51
+ },
52
+ request: {
53
+ timeout: 5000
54
+ }
55
+ }
56
+ ```
57
+
58
+ What changed:
59
+
60
+ - `config.auth` is the supported way to send basic auth credentials.
61
+ - `config.request` is the supported way to pass native fetch controls.
62
+ - Only these request fields are supported: `signal`, `timeout`, and `dispatcher`.
63
+ - Unknown keys under `config.request` now fail validation.
64
+
65
+ ### 2. Couch URLs with embedded credentials are rejected
66
+
67
+ v6 accepted a `couch` string like `http://alice:secret@localhost:5984/mydb`. v7 rejects config values with embedded credentials.
68
+
69
+ Before:
70
+
71
+ ```ts
72
+ const config = {
73
+ couch: 'http://alice:secret@localhost:5984/mydb'
74
+ }
75
+ ```
76
+
77
+ After:
78
+
79
+ ```ts
80
+ const config = {
81
+ couch: 'http://localhost:5984/mydb',
82
+ auth: {
83
+ username: 'alice',
84
+ password: 'secret'
85
+ }
86
+ }
87
+ ```
88
+
89
+ ### 3. Request controls moved to config-level `request`
90
+
91
+ v7 uses native fetch internally, so request customization is intentionally much smaller than the old `needle` surface.
92
+
93
+ Supported:
94
+
95
+ - `config.request.signal`
96
+ - `config.request.timeout`
97
+ - `config.request.dispatcher`
98
+
99
+ Not supported:
100
+
101
+ - Per-call `request` overrides inside `get()` options, `query()` options, or `watchDocs()` options.
102
+ - Any other transport-specific fields from `needle`.
103
+
104
+ Use config overrides instead:
105
+
106
+ ```ts
107
+ const db = bindConfig({
108
+ couch: 'http://localhost:5984/mydb',
109
+ request: { timeout: 5000 }
110
+ })
111
+
112
+ await db
113
+ .options({
114
+ request: {
115
+ timeout: 1000,
116
+ signal: abortController.signal
117
+ }
118
+ })
119
+ .get('doc-123')
120
+ ```
121
+
122
+ ### 4. Single-document write/delete helpers now throw typed errors
123
+
124
+ Several v6 code paths returned parsed CouchDB payloads like `{ ok: false, error: 'conflict' }`. In v7 those same cases now throw.
125
+
126
+ #### `put()`
127
+
128
+ Before:
129
+
130
+ ```ts
131
+ const result = await put(config, {
132
+ _id: 'doc-123',
133
+ _rev: 'stale-rev'
134
+ })
135
+
136
+ if (result.error === 'conflict') {
137
+ // handle stale rev
138
+ }
139
+ ```
140
+
141
+ After:
142
+
143
+ ```ts
144
+ import { ConflictError } from 'hide-a-bed'
145
+
146
+ try {
147
+ await put(config, {
148
+ _id: 'doc-123',
149
+ _rev: 'stale-rev'
150
+ })
151
+ } catch (error) {
152
+ if (error instanceof ConflictError) {
153
+ console.log(error.docId)
154
+ console.log(error.statusCode)
155
+ } else {
156
+ throw error
157
+ }
158
+ }
159
+ ```
160
+
161
+ #### `remove()`
162
+
163
+ Before:
164
+
165
+ ```ts
166
+ const result = await remove(config, 'missing-doc', '1-deadbeef')
167
+
168
+ if (result.error === 'not_found') {
169
+ // handle missing document
170
+ }
171
+ ```
172
+
173
+ After:
174
+
175
+ ```ts
176
+ import { NotFoundError } from 'hide-a-bed'
177
+
178
+ try {
179
+ await remove(config, 'missing-doc', '1-deadbeef')
180
+ } catch (error) {
181
+ if (error instanceof NotFoundError) {
182
+ console.log(error.docId)
183
+ } else {
184
+ throw error
185
+ }
186
+ }
187
+ ```
188
+
189
+ #### `patch()` and `patchDangerously()`
190
+
191
+ Before:
192
+
193
+ - `patch()` returned a conflict-shaped response when `_rev` did not match.
194
+ - `patchDangerously()` could return `{ ok: false, error: 'not_found' }` or a generic failure payload after retry exhaustion.
195
+
196
+ After:
197
+
198
+ - `patch()` throws `ConflictError` for stale revisions.
199
+ - `patch()` throws `NotFoundError` when the document is missing.
200
+ - `patchDangerously()` throws `NotFoundError` when the document is missing.
201
+ - `patchDangerously()` throws `OperationError` when retries are exhausted.
202
+
203
+ This means upgrade code should stop pattern-matching on `result.ok` for unhappy paths and switch to `try`/`catch`.
204
+
205
+ ### 5. Validation failures now throw `ValidationError`
206
+
207
+ In v6, validation failures could bubble out as raw schema issues. In v7 they are wrapped in a first-class `ValidationError`.
208
+
209
+ Affected helpers include:
210
+
211
+ - `get()`
212
+ - `getAtRev()`
213
+ - `bulkGet()`
214
+ - `bulkGetDictionary()`
215
+ - `query()`
216
+
217
+ Before:
218
+
219
+ ```ts
220
+ try {
221
+ await get(config, 'doc-123', {
222
+ validate: { docSchema }
223
+ })
224
+ } catch (issues) {
225
+ // issues might be the raw schema issue array
226
+ }
227
+ ```
228
+
229
+ After:
230
+
231
+ ```ts
232
+ import { ValidationError } from 'hide-a-bed'
233
+
234
+ try {
235
+ await get(config, 'doc-123', {
236
+ validate: { docSchema }
237
+ })
238
+ } catch (error) {
239
+ if (error instanceof ValidationError) {
240
+ console.log(error.docId)
241
+ console.log(error.operation)
242
+ console.log(error.issues)
243
+ } else {
244
+ throw error
245
+ }
246
+ }
247
+ ```
248
+
249
+ ### 6. Request-level failures now use structured operational errors
250
+
251
+ v7 adds exported error classes for request and CouchDB failures:
252
+
253
+ - `HideABedError`
254
+ - `ConflictError`
255
+ - `NotFoundError`
256
+ - `OperationError`
257
+ - `RetryableError`
258
+ - `ValidationError`
259
+
260
+ These errors carry machine-readable fields such as:
261
+
262
+ - `statusCode`
263
+ - `docId`
264
+ - `operation`
265
+ - `couchError`
266
+ - `retryable`
267
+
268
+ This especially affects:
269
+
270
+ - `query()`
271
+ - `bulkGet()`
272
+ - `bulkSave()`
273
+ - `getDBInfo()`
274
+ - `queryStream()`
275
+ - `watchDocs()`
276
+
277
+ Instead of parsing `error.message`, prefer branching on the class and metadata.
278
+
279
+ ### 7. `bulkSave([])` is now a hard error
280
+
281
+ v6 threw a generic `Error('no docs provided')`. v7 throws an `OperationError` with message `Bulk save requires at least one document`.
282
+
283
+ If you intentionally call `bulkSave()` with a maybe-empty array, guard it first:
284
+
285
+ ```ts
286
+ if (docs.length > 0) {
287
+ await bulkSave(config, docs)
288
+ }
289
+ ```
290
+
291
+ ### 8. Streaming and watch flows now respect `config.request`
292
+
293
+ `queryStream()` and `watchDocs()` now run through the same native fetch layer as the rest of the client.
294
+
295
+ Practical migration notes:
296
+
297
+ - Aborting `config.request.signal` aborts the active stream request.
298
+ - `watchDocs().stop()` aborts the active request instead of only destroying the old request object.
299
+ - `watchDocs()` emits a single `end` when stopped or externally aborted.
300
+ - Invalid `watchDocs()` input now throws `OperationError` instead of a generic `Error`.
301
+
302
+ Example:
303
+
304
+ ```ts
305
+ const controller = new AbortController()
306
+
307
+ const watcher = watchDocs(
308
+ {
309
+ couch: 'http://localhost:5984/mydb',
310
+ request: { signal: controller.signal }
311
+ },
312
+ ['doc-1', 'doc-2'],
313
+ change => {
314
+ console.log(change.id)
315
+ }
316
+ )
317
+
318
+ controller.abort()
319
+ ```
320
+
321
+ ## What stays the same
322
+
323
+ - The top-level CRUD, bulk, query, and stream helper names are unchanged.
324
+ - `get()` still returns `null` on 404 by default, and still throws when `throwOnGetNotFound: true`.
325
+ - Bulk APIs still report item-level failures inside their result payloads where that was already the contract.
326
+ - `bindConfig()` and `withRetry()` remain the main composition helpers.
327
+ - `watchDocs()` and `queryStream()` remain in the main package.
328
+
329
+ ## New exports worth using
330
+
331
+ v7 newly exports runtime classes and types that make upgrade code cleaner:
332
+
333
+ - Error classes: `ConflictError`, `HideABedError`, `NotFoundError`, `OperationError`, `RetryableError`, `ValidationError`
334
+ - Config types: `CouchAuth`, `CouchAuthInput`, `RequestOptions`, `RequestOptionsInput`, `Dispatcher`
335
+ - Watch types: `WatchHandle`, `WatchListener`
336
+
337
+ ## Suggested migration order
338
+
339
+ 1. Replace config construction first.
340
+ 2. Update single-document error handling next.
341
+ 3. Update validation error handling.
342
+ 4. Re-test watchers, streams, and timeout/abort behavior.
343
+ 5. Re-run integration tests against CouchDB or the stub package.
344
+
345
+ ## Verification checklist
346
+
347
+ - No code still references `needleOpts`.
348
+ - No Couch URLs still embed credentials.
349
+ - All auth now flows through `config.auth`.
350
+ - Timeouts and abort signals now flow through `config.request`.
351
+ - `put()`, `remove()`, `patch()`, and `patchDangerously()` callers use `try`/`catch` for unhappy paths.
352
+ - Validation callers catch `ValidationError` instead of assuming raw issues.
353
+ - Streaming and watcher tests still pass with cancellation enabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hide-a-bed",
3
- "version": "6.0.0",
3
+ "version": "7.0.0-beta.0",
4
4
  "description": "An abstraction over couchdb calls that includes easy mock/stubs with pouchdb",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",
@@ -32,6 +32,7 @@
32
32
  "typecheck": "tsc --noEmit",
33
33
  "typecheck:watch": "tsc --noEmit --watch",
34
34
  "prepublishOnly": "npm run build",
35
+ "validate": "npm run format && npm run lint && npm run typecheck",
35
36
  "full": "npm run lint:fix && npm run build && npm run clean"
36
37
  },
37
38
  "repository": {
@@ -49,15 +50,14 @@
49
50
  },
50
51
  "homepage": "https://github.com/ryanramage/hide-a-bed#readme",
51
52
  "dependencies": {
52
- "needle": "3.3.1",
53
53
  "stream-chain": "3.4.0",
54
54
  "stream-json": "1.9.1",
55
+ "undici-types": "7.24.3",
55
56
  "zod": "4.2.1"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@eslint/js": "9.39.2",
59
- "@types/needle": "3.3.0",
60
- "@types/node": "22.19.3",
60
+ "@types/node": "24.12.0",
61
61
  "@types/stream-json": "1.7.8",
62
62
  "eslint": "9.39.2",
63
63
  "globals": "16.5.0",
package/schema/config.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import type { StandardSchemaV1 } from '../types/standard-schema.ts'
3
+ import { RequestOptions } from './request.mts'
3
4
 
4
5
  const anyArgs = z.array(z.any())
5
6
 
@@ -12,46 +13,26 @@ const LoggerSchema = z
12
13
  })
13
14
  .or(z.function({ input: anyArgs, output: z.void() }))
14
15
 
15
- export const NeedleBaseOptions = z.object({
16
- json: z.boolean(),
17
- headers: z.record(z.string(), z.string()),
18
- parse_response: z.boolean().optional()
16
+ export const CouchAuth = z.strictObject({
17
+ username: z.string().describe('basic auth username for CouchDB requests'),
18
+ password: z.string().describe('basic auth password for CouchDB requests')
19
19
  })
20
- export type NeedleBaseOptionsSchema = z.infer<typeof NeedleBaseOptions>
21
20
 
22
- export const NeedleOptions = z.object({
23
- json: z.boolean().optional(),
24
- compressed: z.boolean().optional(),
25
- follow_max: z.number().optional(),
26
- follow_set_cookie: z.boolean().optional(),
27
- follow_set_referer: z.boolean().optional(),
28
- follow: z.number().optional(),
29
- timeout: z.number().optional(),
30
- read_timeout: z.number().optional(),
31
- parse_response: z.boolean().optional(),
32
- decode: z.boolean().optional(),
33
- parse_cookies: z.boolean().optional(),
34
- cookies: z.record(z.string(), z.string()).optional(),
35
- headers: z.record(z.string(), z.string()).optional(),
36
- auth: z.enum(['auto', 'digest', 'basic']).optional(),
37
- username: z.string().optional(),
38
- password: z.string().optional(),
39
- proxy: z.string().optional(),
40
- agent: z.any().optional(),
41
- rejectUnauthorized: z.boolean().optional(),
42
- output: z.string().optional(),
43
- parse: z.boolean().optional(),
44
- multipart: z.boolean().optional(),
45
- open_timeout: z.number().optional(),
46
- response_timeout: z.number().optional(),
47
- keepAlive: z.boolean().optional()
21
+ const CouchUrl = z.custom<string | URL>(value => {
22
+ try {
23
+ const url = new URL(value as string | URL)
24
+ return url.username === '' && url.password === ''
25
+ } catch {
26
+ return false
27
+ }
48
28
  })
49
29
 
50
30
  export const CouchConfig = z
51
31
  .strictObject({
32
+ auth: CouchAuth.optional().describe('basic auth credentials for CouchDB requests'),
52
33
  backoffFactor: z.number().optional().default(2).describe('multiplier for exponential backoff'),
53
34
  bindWithRetry: z.boolean().optional().default(true).describe('should we bind with retry'),
54
- couch: z.string().describe('the url of the couch db'),
35
+ couch: CouchUrl.describe('URL of the couch db without embedded credentials'),
55
36
  initialDelay: z
56
37
  .number()
57
38
  .optional()
@@ -61,12 +42,12 @@ export const CouchConfig = z
61
42
  'logging interface supporting winston-like or simple function interface'
62
43
  ),
63
44
  maxRetries: z.number().optional().default(3).describe('maximum number of retry attempts'),
64
- needleOpts: NeedleOptions.optional(),
45
+ request: RequestOptions.optional().describe('default request controls for CouchDB requests'),
65
46
  throwOnGetNotFound: z
66
47
  .boolean()
67
48
  .optional()
68
49
  .default(false)
69
- .describe('if a get is 404 should we throw or return undefined'),
50
+ .describe('if true, get() throws NotFoundError on 404; otherwise it returns null'),
70
51
  useConsoleLogger: z
71
52
  .boolean()
72
53
  .optional()
@@ -77,5 +58,7 @@ export const CouchConfig = z
77
58
  })
78
59
  .describe('The std config object')
79
60
 
61
+ export type CouchAuth = StandardSchemaV1.InferOutput<typeof CouchAuth>
62
+ export type CouchAuthInput = StandardSchemaV1.InferInput<typeof CouchAuth>
80
63
  export type CouchConfig = StandardSchemaV1.InferOutput<typeof CouchConfig>
81
64
  export type CouchConfigInput = StandardSchemaV1.InferInput<typeof CouchConfig>
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+
3
+ const isAbortSignal = (value: unknown): value is AbortSignal => {
4
+ return value instanceof AbortSignal
5
+ }
6
+
7
+ export type Dispatcher = RequestInit['dispatcher']
8
+
9
+ export type RequestOptions = {
10
+ dispatcher?: Dispatcher
11
+ signal?: AbortSignal
12
+ timeout?: number
13
+ }
14
+
15
+ export type RequestOptionsInput = RequestOptions
16
+
17
+ const isDispatcher = (value: unknown): value is Dispatcher => {
18
+ if (typeof value !== 'object' || value === null) return false
19
+ return typeof (value as { dispatch?: unknown }).dispatch === 'function'
20
+ }
21
+
22
+ export const RequestOptions: z.ZodType<RequestOptions, RequestOptionsInput> = z.strictObject({
23
+ dispatcher: z
24
+ .custom<Dispatcher>(isDispatcher, {
25
+ message: 'dispatcher must expose a dispatch method'
26
+ })
27
+ .optional()
28
+ .describe('dispatcher to use for the request'),
29
+ signal: z
30
+ .custom<AbortSignal>(isAbortSignal, {
31
+ message: 'signal must be an AbortSignal'
32
+ })
33
+ .optional()
34
+ .describe('abort signal for the request'),
35
+ timeout: z.number().nonnegative().optional().describe('request timeout in milliseconds')
36
+ })
@@ -2,7 +2,7 @@ import { z } from 'zod'
2
2
  import type { StandardSchemaV1 } from '../../types/standard-schema.ts'
3
3
 
4
4
  export const WatchOptions = z
5
- .object({
5
+ .strictObject({
6
6
  include_docs: z.boolean().default(false),
7
7
  maxRetries: z.number().describe('maximum number of retries before giving up'),
8
8
  initialDelay: z.number().describe('initial delay between retries in milliseconds'),