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
@@ -28,8 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  let zod = require("zod");
29
29
  zod = __toESM(zod);
30
30
  let node_timers_promises = require("node:timers/promises");
31
- let needle = require("needle");
32
- needle = __toESM(needle);
31
+ let node_stream = require("node:stream");
33
32
  let stream_chain = require("stream-chain");
34
33
  stream_chain = __toESM(stream_chain);
35
34
  let stream_json_Parser_js = require("stream-json/Parser.js");
@@ -182,6 +181,21 @@ var QueryBuilder = class {
182
181
  };
183
182
  const createQuery = () => new QueryBuilder();
184
183
 
184
+ //#endregion
185
+ //#region schema/request.mts
186
+ const isAbortSignal = (value) => {
187
+ return value instanceof AbortSignal;
188
+ };
189
+ const isDispatcher = (value) => {
190
+ if (typeof value !== "object" || value === null) return false;
191
+ return typeof value.dispatch === "function";
192
+ };
193
+ const RequestOptions = zod.z.strictObject({
194
+ dispatcher: zod.z.custom(isDispatcher, { message: "dispatcher must expose a dispatch method" }).optional().describe("dispatcher to use for the request"),
195
+ signal: zod.z.custom(isAbortSignal, { message: "signal must be an AbortSignal" }).optional().describe("abort signal for the request"),
196
+ timeout: zod.z.number().nonnegative().optional().describe("request timeout in milliseconds")
197
+ });
198
+
185
199
  //#endregion
186
200
  //#region schema/config.mts
187
201
  const anyArgs = zod.z.array(zod.z.any());
@@ -206,56 +220,61 @@ const LoggerSchema = zod.z.object({
206
220
  input: anyArgs,
207
221
  output: zod.z.void()
208
222
  }));
209
- const NeedleBaseOptions = zod.z.object({
210
- json: zod.z.boolean(),
211
- headers: zod.z.record(zod.z.string(), zod.z.string()),
212
- parse_response: zod.z.boolean().optional()
223
+ const CouchAuth = zod.z.strictObject({
224
+ username: zod.z.string().describe("basic auth username for CouchDB requests"),
225
+ password: zod.z.string().describe("basic auth password for CouchDB requests")
213
226
  });
214
- const NeedleOptions = zod.z.object({
215
- json: zod.z.boolean().optional(),
216
- compressed: zod.z.boolean().optional(),
217
- follow_max: zod.z.number().optional(),
218
- follow_set_cookie: zod.z.boolean().optional(),
219
- follow_set_referer: zod.z.boolean().optional(),
220
- follow: zod.z.number().optional(),
221
- timeout: zod.z.number().optional(),
222
- read_timeout: zod.z.number().optional(),
223
- parse_response: zod.z.boolean().optional(),
224
- decode: zod.z.boolean().optional(),
225
- parse_cookies: zod.z.boolean().optional(),
226
- cookies: zod.z.record(zod.z.string(), zod.z.string()).optional(),
227
- headers: zod.z.record(zod.z.string(), zod.z.string()).optional(),
228
- auth: zod.z.enum([
229
- "auto",
230
- "digest",
231
- "basic"
232
- ]).optional(),
233
- username: zod.z.string().optional(),
234
- password: zod.z.string().optional(),
235
- proxy: zod.z.string().optional(),
236
- agent: zod.z.any().optional(),
237
- rejectUnauthorized: zod.z.boolean().optional(),
238
- output: zod.z.string().optional(),
239
- parse: zod.z.boolean().optional(),
240
- multipart: zod.z.boolean().optional(),
241
- open_timeout: zod.z.number().optional(),
242
- response_timeout: zod.z.number().optional(),
243
- keepAlive: zod.z.boolean().optional()
227
+ const CouchUrl = zod.z.custom((value) => {
228
+ try {
229
+ const url = new URL(value);
230
+ return url.username === "" && url.password === "";
231
+ } catch {
232
+ return false;
233
+ }
244
234
  });
245
235
  const CouchConfig = zod.z.strictObject({
236
+ auth: CouchAuth.optional().describe("basic auth credentials for CouchDB requests"),
246
237
  backoffFactor: zod.z.number().optional().default(2).describe("multiplier for exponential backoff"),
247
238
  bindWithRetry: zod.z.boolean().optional().default(true).describe("should we bind with retry"),
248
- couch: zod.z.string().describe("the url of the couch db"),
239
+ couch: CouchUrl.describe("URL of the couch db without embedded credentials"),
249
240
  initialDelay: zod.z.number().optional().default(1e3).describe("initial retry delay in milliseconds"),
250
241
  logger: LoggerSchema.optional().describe("logging interface supporting winston-like or simple function interface"),
251
242
  maxRetries: zod.z.number().optional().default(3).describe("maximum number of retry attempts"),
252
- needleOpts: NeedleOptions.optional(),
253
- throwOnGetNotFound: zod.z.boolean().optional().default(false).describe("if a get is 404 should we throw or return undefined"),
243
+ request: RequestOptions.optional().describe("default request controls for CouchDB requests"),
244
+ throwOnGetNotFound: zod.z.boolean().optional().default(false).describe("if true, get() throws NotFoundError on 404; otherwise it returns null"),
254
245
  useConsoleLogger: zod.z.boolean().optional().default(false).describe("turn on console as a fallback logger"),
255
246
  "~emitter": zod.z.any().optional().describe("emitter for events"),
256
247
  "~normalizedLogger": zod.z.any().optional()
257
248
  }).describe("The std config object");
258
249
 
250
+ //#endregion
251
+ //#region impl/utils/response.mts
252
+ const isRecord = (value) => {
253
+ return value !== null && typeof value === "object";
254
+ };
255
+ const SUCCESS_STATUS_CODES = {
256
+ bulkGet: [200],
257
+ bulkSave: [201, 202],
258
+ changesFeed: [200],
259
+ database: [200],
260
+ documentDelete: [200, 202],
261
+ documentRead: [200],
262
+ documentWrite: [
263
+ 200,
264
+ 201,
265
+ 202
266
+ ],
267
+ viewQuery: [200],
268
+ viewStream: [200]
269
+ };
270
+ const getCouchError = (value) => {
271
+ if (!isRecord(value) || typeof value.error !== "string") return;
272
+ return value.error;
273
+ };
274
+ const isSuccessStatusCode = (profile, statusCode) => {
275
+ return SUCCESS_STATUS_CODES[profile].includes(statusCode);
276
+ };
277
+
259
278
  //#endregion
260
279
  //#region impl/utils/errors.mts
261
280
  const RETRYABLE_STATUS_CODES = new Set([
@@ -281,6 +300,35 @@ const isNetworkError = (value) => {
281
300
  const candidate = value;
282
301
  return typeof candidate.code === "string" && candidate.code in NETWORK_ERROR_STATUS_MAP;
283
302
  };
303
+ const getNestedNetworkError = (value) => {
304
+ if (isNetworkError(value)) return value;
305
+ if (typeof value !== "object" || value === null) return null;
306
+ const candidate = value;
307
+ return isNetworkError(candidate.cause) ? candidate.cause : null;
308
+ };
309
+ /**
310
+ * Shared base class for operational errors thrown by hide-a-bed.
311
+ *
312
+ * @public
313
+ */
314
+ var HideABedError = class extends Error {
315
+ category;
316
+ couchError;
317
+ docId;
318
+ operation;
319
+ retryable;
320
+ statusCode;
321
+ constructor(message, options) {
322
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
323
+ this.name = "HideABedError";
324
+ this.category = options.category;
325
+ this.couchError = options.couchError;
326
+ this.docId = options.docId;
327
+ this.operation = options.operation;
328
+ this.retryable = options.retryable;
329
+ this.statusCode = options.statusCode;
330
+ }
331
+ };
284
332
  /**
285
333
  * Error thrown when a requested CouchDB document cannot be found.
286
334
  *
@@ -290,21 +338,77 @@ const isNetworkError = (value) => {
290
338
  *
291
339
  * @public
292
340
  */
293
- var NotFoundError = class extends Error {
294
- /**
295
- * Identifier of the missing document.
296
- */
297
- docId;
298
- /**
299
- * Creates a new {@link NotFoundError} instance.
300
- *
301
- * @param docId - The identifier of the document that was not found.
302
- * @param message - Optional custom error message.
303
- */
304
- constructor(docId, message = "Document not found") {
305
- super(message);
341
+ var NotFoundError = class extends HideABedError {
342
+ constructor(docId, options = {}) {
343
+ super(options.message ?? "Document not found", {
344
+ category: "not_found",
345
+ couchError: options.couchError ?? "not_found",
346
+ cause: options.cause,
347
+ docId,
348
+ operation: options.operation,
349
+ retryable: false,
350
+ statusCode: options.statusCode ?? 404
351
+ });
306
352
  this.name = "NotFoundError";
307
- this.docId = docId;
353
+ }
354
+ };
355
+ /**
356
+ * Error thrown when a single-document mutation conflicts with the current revision.
357
+ *
358
+ * @public
359
+ */
360
+ var ConflictError = class extends HideABedError {
361
+ constructor(docId, options = {}) {
362
+ super(options.message ?? "Document update conflict", {
363
+ category: "conflict",
364
+ couchError: options.couchError ?? "conflict",
365
+ cause: options.cause,
366
+ docId,
367
+ operation: options.operation,
368
+ retryable: false,
369
+ statusCode: options.statusCode ?? 409
370
+ });
371
+ this.name = "ConflictError";
372
+ }
373
+ };
374
+ /**
375
+ * Error thrown when an operation fails in a non-retryable way.
376
+ *
377
+ * @public
378
+ */
379
+ var OperationError = class extends HideABedError {
380
+ constructor(message, options = {}) {
381
+ super(message, {
382
+ category: options.category ?? "operation",
383
+ cause: options.cause,
384
+ couchError: options.couchError,
385
+ docId: options.docId,
386
+ operation: options.operation,
387
+ retryable: false,
388
+ statusCode: options.statusCode
389
+ });
390
+ this.name = "OperationError";
391
+ }
392
+ };
393
+ /**
394
+ * Error thrown when schema validation fails for a document, row, key, or value.
395
+ *
396
+ * @public
397
+ */
398
+ var ValidationError = class extends HideABedError {
399
+ issues;
400
+ constructor(options) {
401
+ super(options.message ?? "Validation failed", {
402
+ category: "validation",
403
+ cause: options.cause,
404
+ couchError: options.couchError,
405
+ docId: options.docId,
406
+ operation: options.operation,
407
+ retryable: false,
408
+ statusCode: options.statusCode
409
+ });
410
+ this.name = "ValidationError";
411
+ this.issues = options.issues;
308
412
  }
309
413
  };
310
414
  /**
@@ -316,21 +420,18 @@ var NotFoundError = class extends Error {
316
420
  *
317
421
  * @public
318
422
  */
319
- var RetryableError = class RetryableError extends Error {
320
- /**
321
- * HTTP status code associated with the retryable failure, when available.
322
- */
323
- statusCode;
324
- /**
325
- * Creates a new {@link RetryableError} instance.
326
- *
327
- * @param message - Detailed description of the failure.
328
- * @param statusCode - Optional HTTP status code corresponding to the failure.
329
- */
330
- constructor(message, statusCode) {
331
- super(message);
423
+ var RetryableError = class RetryableError extends HideABedError {
424
+ constructor(message, statusCode, options = {}) {
425
+ super(message, {
426
+ category: options.category ?? "retryable",
427
+ cause: options.cause,
428
+ couchError: options.couchError,
429
+ docId: options.docId,
430
+ operation: options.operation,
431
+ retryable: true,
432
+ statusCode
433
+ });
332
434
  this.name = "RetryableError";
333
- this.statusCode = statusCode;
334
435
  }
335
436
  /**
336
437
  * Determines whether the provided status code should be treated as retryable.
@@ -351,15 +452,45 @@ var RetryableError = class RetryableError extends Error {
351
452
  * @throws {@link RetryableError} When the error maps to a retryable network condition.
352
453
  * @throws {*} Re-throws the original error when it cannot be mapped.
353
454
  */
354
- static handleNetworkError(err) {
355
- if (isNetworkError(err)) {
356
- const statusCode = NETWORK_ERROR_STATUS_MAP[err.code];
357
- if (statusCode) throw new RetryableError(`Network error: ${err.code}`, statusCode);
455
+ static handleNetworkError(err, operation = "request") {
456
+ const networkError = getNestedNetworkError(err);
457
+ if (networkError) {
458
+ const statusCode = NETWORK_ERROR_STATUS_MAP[networkError.code];
459
+ if (statusCode) throw new RetryableError("Network request failed", statusCode, {
460
+ category: "network",
461
+ cause: err,
462
+ operation
463
+ });
358
464
  }
359
465
  throw err;
360
466
  }
361
467
  };
468
+ function createResponseError({ body, defaultMessage, docId, notFoundMessage, operation, statusCode }) {
469
+ const couchError = getCouchError(body);
470
+ if (statusCode === 404 && docId) return new NotFoundError(docId, {
471
+ couchError,
472
+ message: notFoundMessage,
473
+ operation,
474
+ statusCode
475
+ });
476
+ if (statusCode === 409 && docId) return new ConflictError(docId, {
477
+ couchError,
478
+ operation,
479
+ statusCode
480
+ });
481
+ if (RetryableError.isRetryableStatusCode(statusCode)) return new RetryableError(defaultMessage, statusCode, {
482
+ couchError,
483
+ operation
484
+ });
485
+ return new OperationError(defaultMessage, {
486
+ couchError,
487
+ docId,
488
+ operation,
489
+ statusCode
490
+ });
491
+ }
362
492
  function isConflictError(err) {
493
+ if (err instanceof ConflictError) return true;
363
494
  if (typeof err !== "object" || err === null) return false;
364
495
  return err.statusCode === 409;
365
496
  }
@@ -433,27 +564,6 @@ function createLogger(config) {
433
564
  return normalized;
434
565
  }
435
566
 
436
- //#endregion
437
- //#region schema/util.mts
438
- const MergeNeedleOpts = zod.z.function({
439
- input: [CouchConfig, NeedleBaseOptions],
440
- output: NeedleOptions
441
- });
442
-
443
- //#endregion
444
- //#region impl/utils/mergeNeedleOpts.mts
445
- const mergeNeedleOpts = MergeNeedleOpts.implement((config, opts) => {
446
- if (config.needleOpts) return {
447
- ...opts,
448
- ...config.needleOpts,
449
- headers: {
450
- ...opts.headers,
451
- ...config.needleOpts.headers ?? {}
452
- }
453
- };
454
- return opts;
455
- });
456
-
457
567
  //#endregion
458
568
  //#region schema/couch/couch.output.schema.ts
459
569
  /**
@@ -526,8 +636,16 @@ const CouchDBInfo = zod.z.looseObject({
526
636
 
527
637
  //#endregion
528
638
  //#region impl/utils/parseRows.mts
639
+ const createValidationError = (issues, options) => {
640
+ return new ValidationError({
641
+ docId: options.docId,
642
+ issues,
643
+ message: options.defaultMessage ?? "Row validation failed",
644
+ operation: options.operation ?? "request"
645
+ });
646
+ };
529
647
  async function parseRows(rows, options) {
530
- if (!Array.isArray(rows)) throw new Error("invalid rows format");
648
+ if (!Array.isArray(rows)) throw new OperationError(options.defaultMessage ?? "Request failed", { operation: options.operation ?? "request" });
531
649
  const isFinalRow = (row) => row !== "skip";
532
650
  return (await Promise.all(rows.map(async (row) => {
533
651
  try {
@@ -539,12 +657,20 @@ async function parseRows(rows, options) {
539
657
  const parsedRow = zod.z.looseObject(ViewRow.shape).parse(row);
540
658
  if (options.keySchema) {
541
659
  const parsedKey$1 = await options.keySchema["~standard"].validate(row.key);
542
- if (parsedKey$1.issues) throw parsedKey$1.issues;
660
+ if (parsedKey$1.issues) throw createValidationError(parsedKey$1.issues, {
661
+ defaultMessage: options.defaultMessage,
662
+ docId: typeof row.id === "string" ? row.id : void 0,
663
+ operation: options.operation
664
+ });
543
665
  parsedRow.key = parsedKey$1.value;
544
666
  }
545
667
  if (options.valueSchema) {
546
668
  const parsedValue$1 = await options.valueSchema["~standard"].validate(row.value);
547
- if (parsedValue$1.issues) throw parsedValue$1.issues;
669
+ if (parsedValue$1.issues) throw createValidationError(parsedValue$1.issues, {
670
+ defaultMessage: options.defaultMessage,
671
+ docId: typeof row.id === "string" ? row.id : void 0,
672
+ operation: options.operation
673
+ });
548
674
  parsedRow.value = parsedValue$1.value;
549
675
  }
550
676
  return parsedRow;
@@ -555,17 +681,29 @@ async function parseRows(rows, options) {
555
681
  if (options.docSchema) {
556
682
  const parsedDocRes = await options.docSchema["~standard"].validate(row.doc);
557
683
  if (parsedDocRes.issues) if (options.onInvalidDoc === "skip") return "skip";
558
- else throw parsedDocRes.issues;
684
+ else throw createValidationError(parsedDocRes.issues, {
685
+ defaultMessage: options.defaultMessage,
686
+ docId: typeof row.id === "string" ? row.id : void 0,
687
+ operation: options.operation
688
+ });
559
689
  else parsedDoc = parsedDocRes.value;
560
690
  }
561
691
  if (options.keySchema) {
562
692
  const parsedKeyRes = await options.keySchema["~standard"].validate(row.key);
563
- if (parsedKeyRes.issues) throw parsedKeyRes.issues;
693
+ if (parsedKeyRes.issues) throw createValidationError(parsedKeyRes.issues, {
694
+ defaultMessage: options.defaultMessage,
695
+ docId: typeof row.id === "string" ? row.id : void 0,
696
+ operation: options.operation
697
+ });
564
698
  else parsedKey = parsedKeyRes.value;
565
699
  }
566
700
  if (options.valueSchema) {
567
701
  const parsedValueRes = await options.valueSchema["~standard"].validate(row.value);
568
- if (parsedValueRes.issues) throw parsedValueRes.issues;
702
+ if (parsedValueRes.issues) throw createValidationError(parsedValueRes.issues, {
703
+ defaultMessage: options.defaultMessage,
704
+ docId: typeof row.id === "string" ? row.id : void 0,
705
+ operation: options.operation
706
+ });
569
707
  else parsedValue = parsedValueRes.value;
570
708
  }
571
709
  return {
@@ -581,6 +719,137 @@ async function parseRows(rows, options) {
581
719
  }))).filter(isFinalRow);
582
720
  }
583
721
 
722
+ //#endregion
723
+ //#region impl/utils/request.mts
724
+ const definedSignals = (signals) => {
725
+ return signals.filter((signal) => signal != null);
726
+ };
727
+ const composeAbortSignal = (internalSignal, request) => {
728
+ const timeoutSignal = typeof request?.timeout === "number" ? AbortSignal.timeout(request.timeout) : void 0;
729
+ const signals = definedSignals([
730
+ internalSignal,
731
+ request?.signal,
732
+ timeoutSignal
733
+ ]);
734
+ return {
735
+ signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
736
+ timedOut: () => timeoutSignal?.aborted === true
737
+ };
738
+ };
739
+
740
+ //#endregion
741
+ //#region impl/utils/fetch.mts
742
+ const JSON_HEADERS = { "Content-Type": "application/json" };
743
+ const hasHeader = (headers, name) => {
744
+ const expected = name.toLowerCase();
745
+ return Object.keys(headers).some((header) => header.toLowerCase() === expected);
746
+ };
747
+ const toBasicAuthHeader = ({ username, password }) => {
748
+ return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
749
+ };
750
+ const prepareRequest = (options) => {
751
+ const auth = options.auth;
752
+ const headers = { ...options.headers ?? {} };
753
+ if (auth && !hasHeader(headers, "Authorization")) headers.Authorization = toBasicAuthHeader(auth);
754
+ return { headers };
755
+ };
756
+ const isAbortError = (err) => {
757
+ return err instanceof DOMException && err.name === "AbortError";
758
+ };
759
+ const isTimeoutError = (err) => {
760
+ return err instanceof DOMException && err.name === "TimeoutError";
761
+ };
762
+ const encodeBody = (body) => {
763
+ if (body == null) return void 0;
764
+ if (typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof Blob || body instanceof FormData || body instanceof URLSearchParams || body instanceof ReadableStream) return body;
765
+ return JSON.stringify(body);
766
+ };
767
+ const parseJsonResponse = async (response) => {
768
+ if (response.status === 204 || response.status === 205) return null;
769
+ const text = await response.text();
770
+ if (text.trim() === "") return null;
771
+ try {
772
+ return JSON.parse(text);
773
+ } catch (err) {
774
+ if (response.ok) throw err;
775
+ return text;
776
+ }
777
+ };
778
+ async function fetchCouchJson(options) {
779
+ let response;
780
+ const { headers } = prepareRequest(options);
781
+ const { signal, timedOut } = composeAbortSignal(options.signal, options.request);
782
+ try {
783
+ response = await fetch(options.url, {
784
+ method: options.method,
785
+ headers: {
786
+ ...JSON_HEADERS,
787
+ ...headers
788
+ },
789
+ body: encodeBody(options.body),
790
+ signal,
791
+ dispatcher: options.request?.dispatcher
792
+ });
793
+ } catch (err) {
794
+ if (timedOut() || isTimeoutError(err)) throw new RetryableError("Request timed out", 503, {
795
+ category: "network",
796
+ cause: err,
797
+ operation: options.operation
798
+ });
799
+ if (isAbortError(err)) throw err;
800
+ RetryableError.handleNetworkError(err, options.operation);
801
+ }
802
+ return {
803
+ body: await parseJsonResponse(response),
804
+ headers: response.headers,
805
+ statusCode: response.status
806
+ };
807
+ }
808
+ async function fetchCouchStream(options) {
809
+ let response;
810
+ const { headers } = prepareRequest(options);
811
+ const { signal, timedOut } = composeAbortSignal(options.signal, options.request);
812
+ try {
813
+ response = await fetch(options.url, {
814
+ method: options.method,
815
+ headers,
816
+ body: encodeBody(options.body),
817
+ signal,
818
+ dispatcher: options.request?.dispatcher
819
+ });
820
+ } catch (err) {
821
+ if (timedOut() || isTimeoutError(err)) throw new RetryableError("Request timed out", 503, {
822
+ category: "network",
823
+ cause: err,
824
+ operation: options.operation
825
+ });
826
+ if (isAbortError(err)) throw err;
827
+ RetryableError.handleNetworkError(err, options.operation);
828
+ }
829
+ return {
830
+ body: response.body,
831
+ headers: response.headers,
832
+ statusCode: response.status
833
+ };
834
+ }
835
+
836
+ //#endregion
837
+ //#region impl/utils/url.mts
838
+ const ensureDirectoryUrl = (value) => {
839
+ const url = new URL(value);
840
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
841
+ return url;
842
+ };
843
+ const createCouchDbUrl = (value) => {
844
+ return new URL(value);
845
+ };
846
+ const createCouchPathUrl = (path, base) => {
847
+ return new URL(path, ensureDirectoryUrl(base));
848
+ };
849
+ const createCouchDocUrl = (docId, base) => {
850
+ return new URL(encodeURIComponent(docId), ensureDirectoryUrl(base));
851
+ };
852
+
584
853
  //#endregion
585
854
  //#region impl/bulkGet.mts
586
855
  /**
@@ -593,7 +862,7 @@ async function parseRows(rows, options) {
593
862
  * @returns The raw response body from CouchDB
594
863
  *
595
864
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
596
- * @throws {Error} When CouchDB returns a non-retryable error payload.
865
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
597
866
  */
598
867
  async function executeBulkGet(_config, ids, includeDocs) {
599
868
  const configParseResult = CouchConfig.safeParse(_config);
@@ -604,26 +873,35 @@ async function executeBulkGet(_config, ids, includeDocs) {
604
873
  throw configParseResult.error;
605
874
  }
606
875
  const config = configParseResult.data;
607
- const url = `${config.couch}/_all_docs${includeDocs ? "?include_docs=true" : ""}`;
876
+ const url = createCouchPathUrl("_all_docs", config.couch);
877
+ if (includeDocs) url.searchParams.append("include_docs", "true");
608
878
  const payload = { keys: ids };
609
- const mergedOpts = mergeNeedleOpts(config, {
610
- json: true,
611
- headers: { "Content-Type": "application/json" }
612
- });
613
879
  try {
614
- const resp = await (0, needle.default)("post", url, payload, mergedOpts);
880
+ const resp = await fetchCouchJson({
881
+ auth: config.auth,
882
+ method: "POST",
883
+ operation: "request",
884
+ request: config.request,
885
+ url,
886
+ body: payload
887
+ });
615
888
  if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
616
889
  logger.warn(`Retryable status code received: ${resp.statusCode}`);
617
- throw new RetryableError("retryable error during bulk get", resp.statusCode);
890
+ throw new RetryableError("Bulk get failed", resp.statusCode, { operation: "request" });
618
891
  }
619
- if (resp.statusCode !== 200) {
892
+ if (!isSuccessStatusCode("bulkGet", resp.statusCode)) {
620
893
  logger.error(`Unexpected status code: ${resp.statusCode}`);
621
- throw new Error("could not fetch");
894
+ throw createResponseError({
895
+ body: resp.body,
896
+ defaultMessage: "Bulk get failed",
897
+ operation: "request",
898
+ statusCode: resp.statusCode
899
+ });
622
900
  }
623
901
  return resp.body;
624
902
  } catch (err) {
625
903
  logger.error("Network error during bulk get:", err);
626
- RetryableError.handleNetworkError(err);
904
+ RetryableError.handleNetworkError(err, "request");
627
905
  }
628
906
  }
629
907
  /**
@@ -638,16 +916,22 @@ async function executeBulkGet(_config, ids, includeDocs) {
638
916
  * @returns The bulk get response with rows optionally validated against the supplied document schema.
639
917
  *
640
918
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
641
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
642
- * @throws {Error} When CouchDB returns a non-retryable error payload.
919
+ * @throws {ValidationError} When returned documents fail schema validation.
920
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
643
921
  */
644
922
  async function _bulkGetWithOptions(config, ids, options = {}) {
645
923
  const body = await executeBulkGet(config, ids, options.includeDocs ?? true);
646
- if (!body) throw new RetryableError("no response", 503);
647
- if (body.error) throw new Error(typeof body.reason === "string" ? body.reason : "could not fetch");
924
+ if (!body) throw new RetryableError("Bulk get failed", 503, { operation: "request" });
925
+ if (body.error) throw createResponseError({
926
+ body,
927
+ defaultMessage: "Bulk get failed",
928
+ operation: "request"
929
+ });
648
930
  const docSchema = options.validate?.docSchema || CouchDoc;
649
931
  const rows = await parseRows(body.rows, {
932
+ defaultMessage: "Bulk get failed",
650
933
  onInvalidDoc: options.validate?.onInvalidDoc,
934
+ operation: "request",
651
935
  docSchema
652
936
  });
653
937
  return {
@@ -673,8 +957,8 @@ async function _bulkGetWithOptions(config, ids, options = {}) {
673
957
  * @returns The bulk get response with rows optionally validated against the supplied document schema.
674
958
  *
675
959
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
676
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
677
- * @throws {Error} When CouchDB returns a non-retryable error payload.
960
+ * @throws {ValidationError} When returned documents fail schema validation.
961
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
678
962
  */
679
963
  async function bulkGet(config, ids, options = {}) {
680
964
  return _bulkGetWithOptions(config, ids, {
@@ -685,7 +969,7 @@ async function bulkGet(config, ids, options = {}) {
685
969
  /**
686
970
  * Bulk get documents by IDs and return a dictionary of found and not found documents.
687
971
  *
688
- * @template DocSchema - Schema used to validate each returned document, if provided. Note: if a document is found and it fails validation this will throw a Error<StandardSchemaV1.FailureResult["issues"]>.
972
+ * @template DocSchema - Schema used to validate each returned document, if provided. Note: if a document is found and it fails validation this will throw a ValidationError.
689
973
  *
690
974
  * @param config - CouchDB configuration data that is validated before use.
691
975
  * @param ids - Array of document IDs to retrieve.
@@ -694,8 +978,8 @@ async function bulkGet(config, ids, options = {}) {
694
978
  * @returns An object containing found documents and not found rows.
695
979
  *
696
980
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
697
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
698
- * @throws {Error} When CouchDB returns a non-retryable error payload.
981
+ * @throws {ValidationError} When returned documents fail schema validation.
982
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
699
983
  */
700
984
  async function bulkGetDictionary(config, ids, options) {
701
985
  const response = await bulkGet(config, ids, {
@@ -726,60 +1010,65 @@ async function bulkGetDictionary(config, ids, options) {
726
1010
 
727
1011
  //#endregion
728
1012
  //#region impl/get.mts
729
- const ValidSchema = zod.z.custom((value) => {
1013
+ const ValidSchema$1 = zod.z.custom((value) => {
730
1014
  return value !== null && typeof value === "object" && "~standard" in value;
731
1015
  }, { message: "docSchema must be a valid StandardSchemaV1 schema" });
732
- const CouchGetOptions = zod.z.object({
1016
+ const CouchGetOptions = zod.z.strictObject({
733
1017
  rev: zod.z.string().optional().describe("the couch doc revision"),
734
- validate: zod.z.object({ docSchema: ValidSchema.optional() }).optional().describe("optional document validation rules")
1018
+ validate: zod.z.object({ docSchema: ValidSchema$1.optional() }).optional().describe("optional document validation rules")
735
1019
  });
736
- async function _getWithOptions(config, id, options) {
737
- const parsedOptions = CouchGetOptions.parse({
738
- rev: options.rev,
739
- validate: options.validate
740
- });
1020
+ async function _getWithOptions(configInput, id, options) {
1021
+ const config = CouchConfig.parse(configInput);
1022
+ const parsedOptions = CouchGetOptions.parse(options);
741
1023
  const logger = createLogger(config);
742
1024
  const rev = parsedOptions.rev;
743
- const path = rev ? `${id}?rev=${rev}` : id;
744
- const url = `${config.couch}/${path}`;
745
- const requestOptions = mergeNeedleOpts(config, {
746
- json: true,
747
- headers: { "Content-Type": "application/json" }
748
- });
1025
+ const operation = rev ? "getAtRev" : "get";
1026
+ const url = createCouchDocUrl(id, config.couch);
1027
+ if (rev) url.searchParams.set("rev", rev);
749
1028
  logger.info(`Getting document with id: ${id}, rev ${rev ?? "latest"}`);
750
1029
  try {
751
- const resp = await (0, needle.default)("get", url, null, requestOptions);
1030
+ const resp = await fetchCouchJson({
1031
+ auth: config.auth,
1032
+ method: "GET",
1033
+ operation,
1034
+ request: config.request,
1035
+ url
1036
+ });
752
1037
  if (!resp) {
753
1038
  logger.error("No response received from get request");
754
- throw new RetryableError("no response", 503);
1039
+ throw new RetryableError("Request failed", 503, { operation });
755
1040
  }
756
1041
  const body = resp.body ?? null;
757
1042
  if (resp.statusCode === 404) {
758
- if (config.throwOnGetNotFound) {
759
- const reason = typeof body?.reason === "string" ? body.reason : "not_found";
760
- logger.warn(`Document not found (throwing error): ${id}, rev ${rev ?? "latest"}`);
761
- throw new NotFoundError(id, reason);
762
- }
763
- logger.debug(`Document not found (returning undefined): ${id}, rev ${rev ?? "latest"}`);
764
- return null;
1043
+ logger.warn(`Document not found: ${id}, rev ${rev ?? "latest"}`);
1044
+ if (config.throwOnGetNotFound === false) return null;
1045
+ throw new NotFoundError(id, {
1046
+ operation,
1047
+ statusCode: resp.statusCode
1048
+ });
765
1049
  }
766
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
767
- const reason = typeof body?.reason === "string" ? body.reason : "retryable error";
768
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
769
- throw new RetryableError(reason, resp.statusCode);
770
- }
771
- if (resp.statusCode !== 200) {
772
- const reason = typeof body?.reason === "string" ? body.reason : "failed";
1050
+ if (!isSuccessStatusCode("documentRead", resp.statusCode)) {
773
1051
  logger.error(`Unexpected status code: ${resp.statusCode}`);
774
- throw new Error(reason);
1052
+ throw createResponseError({
1053
+ body,
1054
+ defaultMessage: "Failed to fetch document",
1055
+ docId: id,
1056
+ operation,
1057
+ statusCode: resp.statusCode
1058
+ });
775
1059
  }
776
1060
  const typedDoc = await (parsedOptions.validate?.docSchema ?? CouchDoc)["~standard"].validate(body);
777
- if (typedDoc.issues) throw typedDoc.issues;
1061
+ if (typedDoc.issues) throw new ValidationError({
1062
+ docId: id,
1063
+ issues: typedDoc.issues,
1064
+ message: "Document validation failed",
1065
+ operation
1066
+ });
778
1067
  logger.info(`Successfully retrieved document: ${id}, rev ${rev ?? "latest"}`);
779
1068
  return typedDoc.value;
780
1069
  } catch (err) {
781
1070
  logger.error("Error during get operation:", err);
782
- RetryableError.handleNetworkError(err);
1071
+ RetryableError.handleNetworkError(err, operation);
783
1072
  }
784
1073
  }
785
1074
  async function get(config, id, options) {
@@ -867,89 +1156,137 @@ function queryString(options = {}) {
867
1156
  */
868
1157
  async function queryStream(rawConfig, view, options, onRow) {
869
1158
  return new Promise((resolve, reject) => {
870
- const config = CouchConfig.parse(rawConfig);
871
- const logger = createLogger(config);
872
- logger.info(`Starting view query stream: ${view}`);
873
- logger.debug("Query options:", options);
874
- const queryOptions = options ?? {};
875
- let method = "GET";
876
- let payload = null;
877
- let qs = queryString(queryOptions);
878
- logger.debug("Generated query string:", qs);
879
- if (typeof queryOptions.keys !== "undefined") {
880
- const MAX_URL_LENGTH = 2e3;
881
- const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`;
882
- if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) qs += (qs.length > 0 ? "&" : "") + keysAsString;
883
- else {
884
- method = "POST";
885
- payload = { keys: queryOptions.keys };
886
- }
887
- }
888
- const url = `${config.couch}/${view}?${qs}`;
889
- const mergedOpts = mergeNeedleOpts(config, {
890
- json: true,
891
- headers: { "Content-Type": "application/json" },
892
- parse_response: false
893
- });
894
- const parserPipeline = stream_chain.default.chain([
895
- new stream_json_Parser_js.default(),
896
- new stream_json_filters_Pick_js.default({ filter: "rows" }),
897
- new stream_json_streamers_StreamArray_js.default()
898
- ]);
899
- let rowCount = 0;
900
- let settled = false;
901
- const settleReject = (err) => {
902
- if (settled) return;
903
- settled = true;
904
- reject(err);
905
- };
906
- const settleResolve = () => {
907
- if (settled) return;
908
- settled = true;
909
- resolve();
910
- };
911
- let request = null;
912
- parserPipeline.on("data", (chunk) => {
913
- try {
914
- rowCount++;
915
- onRow(chunk.value);
916
- } catch (callbackErr) {
917
- const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr));
918
- parserPipeline.destroy(error);
919
- settleReject(error);
920
- }
921
- });
922
- parserPipeline.on("error", (err) => {
923
- logger.error("Stream parsing error:", err);
924
- parserPipeline.destroy();
925
- settleReject(new Error(`Stream parsing error: ${err.message}`, { cause: err }));
926
- });
927
- parserPipeline.on("end", () => {
928
- logger.info(`Stream completed, processed ${rowCount} rows`);
929
- settleResolve();
930
- });
931
- request = method === "GET" ? needle.default.get(url, mergedOpts) : needle.default.post(url, payload, mergedOpts);
932
- request.on("response", (response) => {
933
- logger.debug(`Received response with status code: ${response.statusCode}`);
934
- if (RetryableError.isRetryableStatusCode(response.statusCode)) {
935
- logger.warn(`Retryable status code received: ${response.statusCode}`);
936
- settleReject(new RetryableError("retryable error during stream query", response.statusCode));
937
- request.destroy();
1159
+ (async () => {
1160
+ const config = CouchConfig.parse(rawConfig);
1161
+ const logger = createLogger(config);
1162
+ logger.info(`Starting view query stream: ${view}`);
1163
+ const queryOptions = ViewOptions.parse(options ?? {});
1164
+ const request = config.request;
1165
+ logger.debug("Query options:", {
1166
+ ...queryOptions,
1167
+ request
1168
+ });
1169
+ let method = "GET";
1170
+ let payload = null;
1171
+ let qs = queryString(queryOptions);
1172
+ logger.debug("Generated query string:", qs);
1173
+ if (typeof queryOptions.keys !== "undefined") {
1174
+ const MAX_URL_LENGTH = 2e3;
1175
+ const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`;
1176
+ if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) qs += (qs.length > 0 ? "&" : "") + keysAsString;
1177
+ else {
1178
+ method = "POST";
1179
+ payload = { keys: queryOptions.keys };
1180
+ }
938
1181
  }
939
- });
940
- request.on("error", (err) => {
941
- logger.error("Network error during stream query:", err);
942
- parserPipeline.destroy(err);
1182
+ const url = createCouchPathUrl(view, config.couch);
1183
+ if (qs) url.search = qs;
1184
+ const requestHeaders = { "Content-Type": "application/json" };
1185
+ const abortController = new AbortController();
1186
+ const requestAbortHandler = () => {
1187
+ const reason = request?.signal?.reason instanceof Error ? request.signal.reason : new DOMException("The operation was aborted.", "AbortError");
1188
+ abortController.abort(reason);
1189
+ responseStream?.destroy(reason);
1190
+ parserPipeline.destroy(reason);
1191
+ settleReject(reason);
1192
+ };
1193
+ const parserPipeline = stream_chain.default.chain([
1194
+ new stream_json_Parser_js.default(),
1195
+ new stream_json_filters_Pick_js.default({ filter: "rows" }),
1196
+ new stream_json_streamers_StreamArray_js.default()
1197
+ ]);
1198
+ let rowCount = 0;
1199
+ let settled = false;
1200
+ const settleReject = (err) => {
1201
+ if (settled) return;
1202
+ settled = true;
1203
+ request?.signal?.removeEventListener("abort", requestAbortHandler);
1204
+ reject(err);
1205
+ };
1206
+ const settleResolve = () => {
1207
+ if (settled) return;
1208
+ settled = true;
1209
+ request?.signal?.removeEventListener("abort", requestAbortHandler);
1210
+ resolve();
1211
+ };
1212
+ let responseStream = null;
1213
+ request?.signal?.addEventListener("abort", requestAbortHandler, { once: true });
1214
+ parserPipeline.on("data", (chunk) => {
1215
+ try {
1216
+ rowCount++;
1217
+ onRow(chunk.value);
1218
+ } catch (callbackErr) {
1219
+ const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr));
1220
+ parserPipeline.destroy(error);
1221
+ settleReject(error);
1222
+ }
1223
+ });
1224
+ parserPipeline.on("error", (err) => {
1225
+ logger.error("Stream parsing error:", err);
1226
+ parserPipeline.destroy();
1227
+ settleReject(new OperationError("Stream parsing failed", {
1228
+ cause: err,
1229
+ operation: "queryStream"
1230
+ }));
1231
+ });
1232
+ parserPipeline.on("end", () => {
1233
+ logger.info(`Stream completed, processed ${rowCount} rows`);
1234
+ settleResolve();
1235
+ });
943
1236
  try {
944
- RetryableError.handleNetworkError(err);
945
- } catch (retryErr) {
946
- settleReject(retryErr);
947
- return;
948
- } finally {
949
- settleReject(err);
1237
+ const response = await fetchCouchStream({
1238
+ auth: config.auth,
1239
+ method,
1240
+ operation: "queryStream",
1241
+ url,
1242
+ body: method === "POST" ? payload : void 0,
1243
+ headers: requestHeaders,
1244
+ request,
1245
+ signal: abortController.signal
1246
+ });
1247
+ logger.debug(`Received response with status code: ${response.statusCode}`);
1248
+ if (RetryableError.isRetryableStatusCode(response.statusCode)) {
1249
+ logger.warn(`Retryable status code received: ${response.statusCode}`);
1250
+ abortController.abort();
1251
+ settleReject(new RetryableError("Stream query failed", response.statusCode, { operation: "queryStream" }));
1252
+ return;
1253
+ }
1254
+ if (!isSuccessStatusCode("viewStream", response.statusCode)) {
1255
+ abortController.abort();
1256
+ settleReject(createResponseError({
1257
+ defaultMessage: "Stream query failed",
1258
+ operation: "queryStream",
1259
+ statusCode: response.statusCode
1260
+ }));
1261
+ return;
1262
+ }
1263
+ if (!response.body) {
1264
+ settleReject(new RetryableError("Stream query failed", 503, { operation: "queryStream" }));
1265
+ return;
1266
+ }
1267
+ responseStream = node_stream.Readable.fromWeb(response.body);
1268
+ responseStream.on("error", (err) => {
1269
+ logger.error("Network error during stream query:", err);
1270
+ parserPipeline.destroy(err);
1271
+ try {
1272
+ RetryableError.handleNetworkError(err, "queryStream");
1273
+ } catch (retryErr) {
1274
+ settleReject(retryErr);
1275
+ return;
1276
+ }
1277
+ });
1278
+ responseStream.pipe(parserPipeline);
1279
+ } catch (err) {
1280
+ logger.error("Network error during stream query:", err);
1281
+ parserPipeline.destroy(err);
1282
+ try {
1283
+ RetryableError.handleNetworkError(err, "queryStream");
1284
+ } catch (retryErr) {
1285
+ settleReject(retryErr);
1286
+ return;
1287
+ }
950
1288
  }
951
- });
952
- request.pipe(parserPipeline);
1289
+ })();
953
1290
  });
954
1291
  }
955
1292
 
@@ -958,35 +1295,46 @@ async function queryStream(rawConfig, view, options, onRow) {
958
1295
  const put = async (configInput, doc) => {
959
1296
  const config = CouchConfig.parse(configInput);
960
1297
  const logger = createLogger(config);
961
- const url = `${config.couch}/${doc._id}`;
1298
+ const url = createCouchDocUrl(doc._id, config.couch);
962
1299
  const body = doc;
963
- const mergedOpts = mergeNeedleOpts(config, {
964
- json: true,
965
- headers: { "Content-Type": "application/json" }
966
- });
967
1300
  logger.info(`Putting document with id: ${doc._id}`);
968
1301
  let resp;
969
1302
  try {
970
- resp = await (0, needle.default)("put", url, body, mergedOpts);
1303
+ resp = await fetchCouchJson({
1304
+ auth: config.auth,
1305
+ method: "PUT",
1306
+ operation: "put",
1307
+ request: config.request,
1308
+ url,
1309
+ body
1310
+ });
971
1311
  } catch (err) {
972
1312
  logger.error("Error during put operation:", err);
973
- RetryableError.handleNetworkError(err);
1313
+ RetryableError.handleNetworkError(err, "put");
974
1314
  }
975
1315
  if (!resp) {
976
1316
  logger.error("No response received from put request");
977
- throw new RetryableError("no response", 503);
1317
+ throw new RetryableError("Put failed", 503, { operation: "put" });
978
1318
  }
979
- const result = resp?.body || {};
1319
+ const result = { ...isRecord(resp.body) ? resp.body : {} };
980
1320
  result.statusCode = resp.statusCode;
981
1321
  if (resp.statusCode === 409) {
982
1322
  logger.warn(`Conflict detected for document: ${doc._id}`);
983
- result.ok = false;
984
- result.error = "conflict";
985
- return CouchPutResponse.parse(result);
1323
+ throw new ConflictError(doc._id, {
1324
+ couchError: typeof result.error === "string" ? result.error : void 0,
1325
+ operation: "put",
1326
+ statusCode: resp.statusCode
1327
+ });
986
1328
  }
987
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
988
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
989
- throw new RetryableError(result.reason || "retryable error", resp.statusCode);
1329
+ if (!isSuccessStatusCode("documentWrite", resp.statusCode) || !result.ok) {
1330
+ logger.error(`Unexpected status code: ${resp.statusCode}`);
1331
+ throw createResponseError({
1332
+ body: resp.body,
1333
+ defaultMessage: "Put failed",
1334
+ docId: doc._id,
1335
+ operation: "put",
1336
+ statusCode: resp.statusCode
1337
+ });
990
1338
  }
991
1339
  logger.info(`Successfully saved document: ${doc._id}`);
992
1340
  return CouchPutResponse.parse(result);
@@ -1004,7 +1352,10 @@ const PatchProperties = zod.z.looseObject({ _rev: zod.z.string("_rev is required
1004
1352
  * @param _properties - Properties to merge into the document (must include _rev)
1005
1353
  * @returns The result of the put operation
1006
1354
  *
1007
- * @throws Error if the _rev does not match or other errors occur
1355
+ * @throws {ConflictError} When the supplied `_rev` does not match the current document revision.
1356
+ * @throws {NotFoundError} When the document does not exist.
1357
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs while reading or saving.
1358
+ * @throws {OperationError} When a non-retryable operational failure occurs.
1008
1359
  */
1009
1360
  const patch = async (configInput, id, _properties) => {
1010
1361
  const config = CouchConfig.parse(configInput);
@@ -1012,12 +1363,15 @@ const patch = async (configInput, id, _properties) => {
1012
1363
  const logger = createLogger(configInput);
1013
1364
  logger.info(`Starting patch operation for document ${id}`);
1014
1365
  logger.debug("Patch properties:", properties);
1015
- const doc = await get(config, id);
1016
- if (doc?._rev !== properties._rev) return {
1017
- statusCode: 409,
1018
- ok: false,
1019
- error: "conflict"
1020
- };
1366
+ const doc = await get({
1367
+ ...config,
1368
+ throwOnGetNotFound: true
1369
+ }, id);
1370
+ if (!doc) throw new OperationError("Patch failed", {
1371
+ docId: id,
1372
+ operation: "patch"
1373
+ });
1374
+ if (doc._rev !== properties._rev) throw new ConflictError(id, { operation: "patch" });
1021
1375
  const updatedDoc = {
1022
1376
  ...doc,
1023
1377
  ...properties
@@ -1038,7 +1392,9 @@ const patch = async (configInput, id, _properties) => {
1038
1392
  * @param properties - Properties to merge into the document
1039
1393
  * @returns The result of the put operation or an error if max retries are exceeded
1040
1394
  *
1041
- * @throws Error if max retries are exceeded or other errors occur
1395
+ * @throws {NotFoundError} When the document does not exist.
1396
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs before retries are exhausted.
1397
+ * @throws {OperationError} When retries are exhausted or a non-retryable operational failure occurs.
1042
1398
  */
1043
1399
  const patchDangerously = async (configInput, id, properties) => {
1044
1400
  const config = CouchConfig.parse(configInput);
@@ -1048,65 +1404,66 @@ const patchDangerously = async (configInput, id, properties) => {
1048
1404
  let attempts = 0;
1049
1405
  logger.info(`Starting patch operation for document ${id}`);
1050
1406
  logger.debug("Patch properties:", properties);
1407
+ let lastError;
1051
1408
  while (attempts <= maxRetries) {
1052
1409
  logger.debug(`Attempt ${attempts + 1} of ${maxRetries + 1}`);
1053
1410
  try {
1054
- const doc = await get(config, id);
1055
- if (!doc) {
1056
- logger.warn(`Document ${id} not found`);
1057
- return {
1058
- ok: false,
1059
- statusCode: 404,
1060
- error: "not_found"
1061
- };
1062
- }
1411
+ const doc = await get({
1412
+ ...config,
1413
+ throwOnGetNotFound: true
1414
+ }, id);
1415
+ if (!doc) throw new OperationError("Patch failed", {
1416
+ docId: id,
1417
+ operation: "patchDangerously"
1418
+ });
1063
1419
  const updatedDoc = {
1064
1420
  ...doc,
1065
1421
  ...properties
1066
1422
  };
1067
1423
  logger.debug("Merged document:", updatedDoc);
1068
1424
  const result = await put(config, updatedDoc);
1069
- if (result.ok) {
1070
- logger.info(`Successfully patched document ${id}, rev: ${result.rev}`);
1071
- return result;
1072
- }
1073
- attempts++;
1074
- if (attempts > maxRetries) {
1075
- logger.error(`Failed to patch ${id} after ${maxRetries} attempts`);
1076
- throw new Error(`Failed to patch after ${maxRetries} attempts`);
1077
- }
1078
- logger.warn(`Conflict detected for ${id}, retrying (attempt ${attempts})`);
1079
- await (0, node_timers_promises.setTimeout)(delay);
1080
- delay *= config.backoffFactor || 2;
1081
- logger.debug(`Next retry delay: ${delay}ms`);
1425
+ logger.info(`Successfully patched document ${id}, rev: ${result.rev}`);
1426
+ return result;
1082
1427
  } catch (err) {
1083
- if (typeof err === "object" && err !== null && "message" in err && err.message === "not_found") {
1084
- logger.warn(`Document ${id} not found during patch operation`);
1085
- return {
1086
- ok: false,
1087
- statusCode: 404,
1088
- error: "not_found"
1089
- };
1090
- }
1428
+ if (!(err instanceof Error)) throw err;
1429
+ if (!(err instanceof ConflictError) && !(err instanceof RetryableError)) throw err;
1430
+ lastError = err;
1091
1431
  attempts++;
1092
1432
  if (attempts > maxRetries) {
1093
- const error = `Failed to patch after ${maxRetries} attempts: ${err}`;
1094
- logger.error(error);
1095
- return {
1096
- ok: false,
1097
- statusCode: 500,
1098
- error
1099
- };
1433
+ logger.error(`Failed to patch ${id} after ${maxRetries} attempts`, err);
1434
+ throw new OperationError("Patch failed", {
1435
+ cause: err,
1436
+ couchError: err instanceof HideABedError ? err.couchError : void 0,
1437
+ docId: id,
1438
+ operation: "patchDangerously",
1439
+ statusCode: err instanceof HideABedError ? err.statusCode : void 0
1440
+ });
1100
1441
  }
1101
1442
  logger.warn(`Error during patch attempt ${attempts}: ${err}`);
1102
1443
  await (0, node_timers_promises.setTimeout)(delay);
1444
+ delay *= config.backoffFactor || 2;
1103
1445
  logger.debug(`Retrying after ${delay}ms`);
1104
1446
  }
1105
1447
  }
1448
+ throw new OperationError("Patch failed", {
1449
+ cause: lastError,
1450
+ docId: id,
1451
+ operation: "patchDangerously"
1452
+ });
1106
1453
  };
1107
1454
 
1108
1455
  //#endregion
1109
1456
  //#region impl/query.mts
1457
+ const ValidSchema = zod.z.custom((value) => {
1458
+ return value !== null && typeof value === "object" && "~standard" in value;
1459
+ }, { message: "schema must be a valid StandardSchemaV1 schema" });
1460
+ const QueryValidationSchema = zod.z.object({
1461
+ docSchema: ValidSchema.optional(),
1462
+ keySchema: ValidSchema.optional(),
1463
+ onInvalidDoc: zod.z.enum(["skip", "throw"]).optional(),
1464
+ valueSchema: ValidSchema.optional()
1465
+ }).optional();
1466
+ const QueryOptionsSchema = ViewOptions.extend({ validate: QueryValidationSchema }).strict();
1110
1467
  /**
1111
1468
  * Executes a CouchDB view query with optional schema validation and automatic handling
1112
1469
  * of HTTP method selection, query string construction, and retryable errors.
@@ -1126,69 +1483,85 @@ const patchDangerously = async (configInput, id, properties) => {
1126
1483
  * @returns The parsed view response with rows validated against the supplied schemas.
1127
1484
  *
1128
1485
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
1129
- * @throws {Error<Array<StandardSchemaV1.Issue>>} When the configuration or validation schemas fail to parse.
1130
- * @throws {Error} When CouchDB returns a non-retryable error payload.
1486
+ * @throws {ValidationError} When row, key, value, or included document validation fails.
1487
+ * @throws {OperationError} When CouchDB returns a non-retryable response or malformed row payload.
1131
1488
  */
1132
1489
  async function query(_config, view, options = {}) {
1133
1490
  const configParseResult = CouchConfig.safeParse(_config);
1491
+ const parsedOptions = QueryOptionsSchema.parse(options || {});
1134
1492
  const logger = createLogger(_config);
1135
1493
  logger.info(`Starting view query: ${view}`);
1136
- logger.debug("Query options:", ViewOptions.parse(options || {}));
1494
+ logger.debug("Query options:", parsedOptions);
1137
1495
  if (!configParseResult.success) {
1138
1496
  logger.error(`Invalid configuration provided: ${zod.z.prettifyError(configParseResult.error)}`);
1139
1497
  throw configParseResult.error;
1140
1498
  }
1141
1499
  const config = configParseResult.data;
1142
- let qs = queryString(options);
1143
- let method = "get";
1500
+ let qs = queryString(parsedOptions);
1501
+ let method = "GET";
1144
1502
  let payload = null;
1145
- const mergedOpts = mergeNeedleOpts(config, {
1146
- json: true,
1147
- headers: { "Content-Type": "application/json" }
1148
- });
1149
- if (typeof options.keys !== "undefined") {
1503
+ if (typeof parsedOptions.keys !== "undefined") {
1150
1504
  const MAX_URL_LENGTH = 2e3;
1151
- const _options = structuredClone(options);
1152
- delete _options.keys;
1153
- qs = queryString(_options);
1154
- const keysAsString = `keys=${JSON.stringify(options.keys)}`;
1505
+ const { keys, validate, ...queryableOptions } = parsedOptions;
1506
+ qs = queryString(queryableOptions);
1507
+ const keysAsString = `keys=${JSON.stringify(keys)}`;
1155
1508
  if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
1156
- method = "get";
1509
+ method = "GET";
1157
1510
  if (qs.length > 0) qs += "&";
1158
1511
  else qs = "";
1159
1512
  qs += keysAsString;
1160
1513
  } else {
1161
- method = "post";
1162
- payload = { keys: options.keys };
1514
+ method = "POST";
1515
+ payload = { keys: parsedOptions.keys };
1163
1516
  }
1164
1517
  }
1165
1518
  logger.debug("Generated query string:", qs);
1166
- const url = `${config.couch}/${view}?${qs}`;
1519
+ const url = createCouchPathUrl(view, config.couch);
1520
+ if (qs) url.search = qs;
1167
1521
  let results;
1168
1522
  try {
1169
1523
  logger.debug(`Sending ${method} request to: ${url}`);
1170
- results = method === "get" ? await (0, needle.default)("get", url, mergedOpts) : await (0, needle.default)("post", url, payload, mergedOpts);
1524
+ results = await fetchCouchJson({
1525
+ auth: config.auth,
1526
+ method,
1527
+ operation: "query",
1528
+ request: config.request,
1529
+ url,
1530
+ body: method === "POST" ? payload : void 0
1531
+ });
1171
1532
  } catch (err) {
1172
1533
  logger.error("Network error during query:", err);
1173
- RetryableError.handleNetworkError(err);
1534
+ RetryableError.handleNetworkError(err, "query");
1174
1535
  }
1175
1536
  if (!results) {
1176
1537
  logger.error("No response received from query request");
1177
- throw new RetryableError("no response", 503);
1538
+ throw new RetryableError("Query failed", 503, { operation: "query" });
1178
1539
  }
1179
1540
  const body = results.body;
1180
- if (RetryableError.isRetryableStatusCode(results.statusCode)) {
1181
- logger.warn(`Retryable status code received: ${results.statusCode}`);
1182
- throw new RetryableError(body.error || "retryable error during query", results.statusCode);
1183
- }
1184
- if (body.error) {
1185
- logger.error(`Query error: ${JSON.stringify(body)}`);
1186
- throw new Error(`CouchDB query error: ${body.error} - ${body.reason || ""}`);
1541
+ if (!isSuccessStatusCode("viewQuery", results.statusCode) || body.error) {
1542
+ if (body.error) logger.error(`Query error: ${JSON.stringify(body)}`);
1543
+ else logger.error(`Unexpected status code: ${results.statusCode}`);
1544
+ throw createResponseError({
1545
+ body,
1546
+ defaultMessage: "Query failed",
1547
+ operation: "query",
1548
+ statusCode: results.statusCode
1549
+ });
1187
1550
  }
1188
- if (options.validate && body.rows) body.rows = await parseRows(body.rows, options.validate);
1551
+ const rows = options.validate && body.rows ? await parseRows(body.rows, {
1552
+ ...options.validate,
1553
+ defaultMessage: "Query failed",
1554
+ operation: "query"
1555
+ }) : body.rows ?? [];
1189
1556
  logger.info(`Successfully executed view query: ${view}`);
1190
- logger.debug("Query response:", body);
1191
- return body;
1557
+ logger.debug("Query response:", {
1558
+ ...body,
1559
+ rows
1560
+ });
1561
+ return {
1562
+ ...body,
1563
+ rows
1564
+ };
1192
1565
  }
1193
1566
 
1194
1567
  //#endregion
@@ -1200,35 +1573,35 @@ const setupEmitter = (config) => {
1200
1573
 
1201
1574
  //#endregion
1202
1575
  //#region impl/utils/transactionErrors.mts
1203
- var TransactionSetupError = class extends Error {
1576
+ var TransactionSetupError = class extends OperationError {
1204
1577
  details;
1205
1578
  constructor(message, details = {}) {
1206
- super(message);
1579
+ super(message, { category: "transaction" });
1207
1580
  this.name = "TransactionSetupError";
1208
1581
  this.details = details;
1209
1582
  }
1210
1583
  };
1211
- var TransactionVersionConflictError = class extends Error {
1584
+ var TransactionVersionConflictError = class extends OperationError {
1212
1585
  conflictingIds;
1213
1586
  constructor(conflictingIds) {
1214
- super(`Revision mismatch for documents: ${conflictingIds.join(", ")}`);
1587
+ super(`Revision mismatch for documents: ${conflictingIds.join(", ")}`, { category: "transaction" });
1215
1588
  this.name = "TransactionVersionConflictError";
1216
1589
  this.conflictingIds = conflictingIds;
1217
1590
  }
1218
1591
  };
1219
- var TransactionBulkOperationError = class extends Error {
1592
+ var TransactionBulkOperationError = class extends OperationError {
1220
1593
  failedDocs;
1221
1594
  constructor(failedDocs) {
1222
- super(`Failed to save documents: ${failedDocs.map((d) => d.id).join(", ")}`);
1595
+ super(`Failed to save documents: ${failedDocs.map((d) => d.id).join(", ")}`, { category: "transaction" });
1223
1596
  this.name = "TransactionBulkOperationError";
1224
1597
  this.failedDocs = failedDocs;
1225
1598
  }
1226
1599
  };
1227
- var TransactionRollbackError = class extends Error {
1600
+ var TransactionRollbackError = class extends OperationError {
1228
1601
  originalError;
1229
1602
  rollbackResults;
1230
1603
  constructor(message, originalError, rollbackResults) {
1231
- super(message);
1604
+ super(message, { category: "transaction" });
1232
1605
  this.name = "TransactionRollbackError";
1233
1606
  this.originalError = originalError;
1234
1607
  this.rollbackResults = rollbackResults;
@@ -1248,39 +1621,48 @@ var TransactionRollbackError = class extends Error {
1248
1621
  * @returns {Promise<BulkSaveResponse>} - The response from CouchDB after the bulk save operation.
1249
1622
  *
1250
1623
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
1251
- * @throws {Error} When CouchDB returns a non-retryable error payload.
1624
+ * @throws {OperationError} When bulk save input is invalid or CouchDB returns a non-retryable request-level failure.
1252
1625
  */
1253
1626
  const bulkSave = async (config, docs) => {
1254
- const logger = createLogger(config);
1627
+ const parsedConfig = CouchConfig.parse(config);
1628
+ const logger = createLogger(parsedConfig);
1255
1629
  if (docs == null || !docs.length) {
1256
1630
  logger.error("bulkSave called with no docs");
1257
- throw new Error("no docs provided");
1631
+ throw new OperationError("Bulk save requires at least one document", { operation: "request" });
1258
1632
  }
1259
1633
  logger.info(`Starting bulk save of ${docs.length} documents`);
1260
- const url = `${config.couch}/_bulk_docs`;
1634
+ const url = createCouchPathUrl("_bulk_docs", parsedConfig.couch);
1261
1635
  const body = { docs };
1262
- const mergedOpts = mergeNeedleOpts(config, {
1263
- json: true,
1264
- headers: { "Content-Type": "application/json" }
1265
- });
1266
1636
  let resp;
1267
1637
  try {
1268
- resp = await (0, needle.default)("post", url, body, mergedOpts);
1638
+ resp = await fetchCouchJson({
1639
+ auth: parsedConfig.auth,
1640
+ method: "POST",
1641
+ operation: "request",
1642
+ request: parsedConfig.request,
1643
+ url,
1644
+ body
1645
+ });
1269
1646
  } catch (err) {
1270
1647
  logger.error("Network error during bulk save:", err);
1271
- RetryableError.handleNetworkError(err);
1648
+ RetryableError.handleNetworkError(err, "request");
1272
1649
  }
1273
1650
  if (!resp) {
1274
1651
  logger.error("No response received from bulk save request");
1275
- throw new RetryableError("no response", 503);
1652
+ throw new RetryableError("Bulk save failed", 503, { operation: "request" });
1276
1653
  }
1277
1654
  if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1278
1655
  logger.warn(`Retryable status code received: ${resp.statusCode}`);
1279
- throw new RetryableError("retryable error during bulk save", resp.statusCode);
1656
+ throw new RetryableError("Bulk save failed", resp.statusCode, { operation: "request" });
1280
1657
  }
1281
- if (resp.statusCode !== 201) {
1658
+ if (!isSuccessStatusCode("bulkSave", resp.statusCode)) {
1282
1659
  logger.error(`Unexpected status code: ${resp.statusCode}`);
1283
- throw new Error("could not save");
1660
+ throw createResponseError({
1661
+ body: resp.body,
1662
+ defaultMessage: "Bulk save failed",
1663
+ operation: "request",
1664
+ statusCode: resp.statusCode
1665
+ });
1284
1666
  }
1285
1667
  const results = resp?.body || [];
1286
1668
  return BulkSaveResponse.parse(results);
@@ -1343,16 +1725,21 @@ const bulkSaveTransaction = async (config, transactionId, docs) => {
1343
1725
  changes: docs,
1344
1726
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1345
1727
  };
1346
- let transactionResponse = await _put(transactionDoc);
1728
+ let transactionResponse;
1729
+ try {
1730
+ transactionResponse = await _put(transactionDoc);
1731
+ } catch (error) {
1732
+ if (error instanceof ConflictError) throw new TransactionSetupError("Failed to create transaction document", {
1733
+ error: error.couchError,
1734
+ response: error
1735
+ });
1736
+ throw error;
1737
+ }
1347
1738
  logger.debug("Transaction document created:", transactionDoc, transactionResponse);
1348
1739
  await emitter.emit("transaction-created", {
1349
1740
  transactionResponse,
1350
1741
  txnDoc: transactionDoc
1351
1742
  });
1352
- if (transactionResponse.error) throw new TransactionSetupError("Failed to create transaction document", {
1353
- error: transactionResponse.error,
1354
- response: transactionResponse
1355
- });
1356
1743
  const existingDocs = await bulkGetDictionary(config, docs.map((d) => d._id));
1357
1744
  logger.debug("Fetched current revisions of documents:", existingDocs);
1358
1745
  await emitter.emit("transaction-revs-fetched", existingDocs);
@@ -1445,44 +1832,44 @@ const bulkSaveTransaction = async (config, transactionId, docs) => {
1445
1832
  const remove = async (configInput, id, rev) => {
1446
1833
  const config = CouchConfig.parse(configInput);
1447
1834
  const logger = createLogger(config);
1448
- const url = `${config.couch}/${id}?rev=${rev}`;
1449
- const mergedOpts = mergeNeedleOpts(config, {
1450
- json: true,
1451
- headers: { "Content-Type": "application/json" }
1452
- });
1835
+ const url = createCouchDocUrl(id, config.couch);
1836
+ url.searchParams.set("rev", rev);
1453
1837
  logger.info(`Deleting document with id: ${id}`);
1454
1838
  let resp;
1455
1839
  try {
1456
- resp = await (0, needle.default)("delete", url, null, mergedOpts);
1840
+ resp = await fetchCouchJson({
1841
+ auth: config.auth,
1842
+ method: "DELETE",
1843
+ operation: "remove",
1844
+ request: config.request,
1845
+ url
1846
+ });
1457
1847
  } catch (err) {
1458
1848
  logger.error("Error during delete operation:", err);
1459
- RetryableError.handleNetworkError(err);
1849
+ RetryableError.handleNetworkError(err, "remove");
1460
1850
  }
1461
1851
  if (!resp) {
1462
1852
  logger.error("No response received from delete request");
1463
- throw new RetryableError("no response", 503);
1464
- }
1465
- let result;
1466
- if (typeof resp.body === "string") try {
1467
- result = JSON.parse(resp.body);
1468
- } catch {
1469
- result = {};
1853
+ throw new RetryableError("Remove failed", 503, { operation: "remove" });
1470
1854
  }
1471
- else result = resp.body || {};
1855
+ const result = { ...isRecord(resp.body) ? resp.body : {} };
1472
1856
  result.statusCode = resp.statusCode;
1473
1857
  if (resp.statusCode === 404) {
1474
1858
  logger.warn(`Document not found for deletion: ${id}`);
1475
- result.ok = false;
1476
- result.error = "not_found";
1477
- return CouchPutResponse.parse(result);
1478
- }
1479
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1480
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
1481
- throw new RetryableError(result.reason || "retryable error", resp.statusCode);
1859
+ throw new NotFoundError(id, {
1860
+ operation: "remove",
1861
+ statusCode: resp.statusCode
1862
+ });
1482
1863
  }
1483
- if (resp.statusCode !== 200) {
1864
+ if (!isSuccessStatusCode("documentDelete", resp.statusCode) || !result.ok) {
1484
1865
  logger.error(`Unexpected status code: ${resp.statusCode}`);
1485
- throw new Error(result.reason || "failed");
1866
+ throw createResponseError({
1867
+ body: resp.body,
1868
+ defaultMessage: "Remove failed",
1869
+ docId: id,
1870
+ operation: "remove",
1871
+ statusCode: resp.statusCode
1872
+ });
1486
1873
  }
1487
1874
  logger.info(`Successfully deleted document: ${id}`);
1488
1875
  return CouchPutResponse.parse(result);
@@ -1512,7 +1899,8 @@ const remove = async (configInput, id, rev) => {
1512
1899
  * console.log(results);
1513
1900
  * ```
1514
1901
  *
1515
- * @throws Will throw an error if the provided configuration is invalid or if the bulk delete operation fails.
1902
+ * @throws {RetryableError} When the bulk request fails with a retryable transport or HTTP error.
1903
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
1516
1904
  */
1517
1905
  const bulkRemove = async (configInput, ids) => {
1518
1906
  const config = CouchConfig.parse(configInput);
@@ -1554,7 +1942,8 @@ const bulkRemove = async (configInput, ids) => {
1554
1942
  * console.log(results);
1555
1943
  * ```
1556
1944
  *
1557
- * @throws Will throw an error if the provided configuration is invalid or if any delete operation fails.
1945
+ * @throws {RetryableError} When a request-level transport or retryable HTTP failure occurs.
1946
+ * @throws {OperationError} When a request fails in a non-retryable way.
1558
1947
  */
1559
1948
  const bulkRemoveMap = async (configInput, ids) => {
1560
1949
  const config = CouchConfig.parse(configInput);
@@ -1583,7 +1972,7 @@ const bulkRemoveMap = async (configInput, ids) => {
1583
1972
  * @param configInput - The CouchDB configuration input.
1584
1973
  * @returns A promise that resolves to the CouchDB database information.
1585
1974
  * @throws {RetryableError} `RetryableError` If a retryable error occurs during the request.
1586
- * @throws {Error} `Error` For other non-retryable errors.
1975
+ * @throws {OperationError} `OperationError` For other non-retryable response failures.
1587
1976
  *
1588
1977
  * @example
1589
1978
  * ```ts
@@ -1603,27 +1992,34 @@ const bulkRemoveMap = async (configInput, ids) => {
1603
1992
  const getDBInfo = async (configInput) => {
1604
1993
  const config = CouchConfig.parse(configInput);
1605
1994
  const logger = createLogger(config);
1606
- const url = `${config.couch}`;
1995
+ const url = createCouchDbUrl(config.couch);
1607
1996
  let resp;
1608
1997
  try {
1609
- resp = await (0, needle.default)("get", url, mergeNeedleOpts(config, {
1610
- json: true,
1611
- headers: { "Content-Type": "application/json" }
1612
- }));
1998
+ resp = await fetchCouchJson({
1999
+ auth: config.auth,
2000
+ method: "GET",
2001
+ operation: "getDBInfo",
2002
+ request: config.request,
2003
+ url
2004
+ });
2005
+ if (!isSuccessStatusCode("database", resp.statusCode)) {
2006
+ logger.error(`Non-success status code received: ${resp.statusCode}`);
2007
+ throw createResponseError({
2008
+ body: resp.body,
2009
+ defaultMessage: "Failed to fetch database info",
2010
+ operation: "getDBInfo",
2011
+ statusCode: resp.statusCode
2012
+ });
2013
+ }
1613
2014
  } catch (err) {
1614
2015
  logger.error("Error during get operation:", err);
1615
- RetryableError.handleNetworkError(err);
2016
+ RetryableError.handleNetworkError(err, "getDBInfo");
1616
2017
  }
1617
2018
  if (!resp) {
1618
2019
  logger.error("No response received from get request");
1619
- throw new RetryableError("no response", 503);
2020
+ throw new RetryableError("Failed to fetch database info", 503, { operation: "getDBInfo" });
1620
2021
  }
1621
- const result = resp.body;
1622
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1623
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
1624
- throw new RetryableError(result.reason ?? "retryable error", resp.statusCode);
1625
- }
1626
- return CouchDBInfo.parse(result);
2022
+ return CouchDBInfo.parse(resp.body);
1627
2023
  };
1628
2024
 
1629
2025
  //#endregion
@@ -1719,7 +2115,7 @@ async function removeLock(configInput, docId, lockOptions) {
1719
2115
 
1720
2116
  //#endregion
1721
2117
  //#region schema/sugar/watch.mts
1722
- const WatchOptions = zod.z.object({
2118
+ const WatchOptions = zod.z.strictObject({
1723
2119
  include_docs: zod.z.boolean().default(false),
1724
2120
  maxRetries: zod.z.number().describe("maximum number of retries before giving up"),
1725
2121
  initialDelay: zod.z.number().describe("initial delay between retries in milliseconds"),
@@ -1745,112 +2141,155 @@ function watchDocs(configInput, docIds, onChange, optionsInput = {}) {
1745
2141
  const options = WatchOptions.parse(optionsInput);
1746
2142
  const logger = createLogger(config);
1747
2143
  const emitter = new events.EventEmitter();
2144
+ const request = config.request;
1748
2145
  let lastSeq = null;
1749
2146
  let stopping = false;
2147
+ let stopEndEmitted = false;
1750
2148
  let retryCount = 0;
1751
- let currentRequest = null;
2149
+ const lifecycleAbortController = new AbortController();
2150
+ let currentAbortController = null;
1752
2151
  const maxRetries = options.maxRetries || 10;
1753
2152
  const initialDelay = options.initialDelay || 1e3;
1754
2153
  const maxDelay = options.maxDelay || 3e4;
1755
2154
  const _docIds = Array.isArray(docIds) ? docIds : [docIds];
1756
- if (_docIds.length === 0) throw new Error("docIds must be a non-empty array");
1757
- if (_docIds.length > 100) throw new Error("docIds must be an array of 100 or fewer elements");
2155
+ if (_docIds.length === 0) throw new OperationError("docIds must be a non-empty array", { operation: "watchDocs" });
2156
+ if (_docIds.length > 100) throw new OperationError("docIds must be an array of 100 or fewer elements", { operation: "watchDocs" });
2157
+ const emitStopEnd = () => {
2158
+ if (stopEndEmitted) return;
2159
+ stopEndEmitted = true;
2160
+ emitter.emit("end", { lastSeq });
2161
+ };
2162
+ const stopWatching = () => {
2163
+ if (stopping) return;
2164
+ stopping = true;
2165
+ lifecycleAbortController.abort();
2166
+ currentAbortController?.abort();
2167
+ request?.signal?.removeEventListener("abort", handleExternalAbort);
2168
+ emitStopEnd();
2169
+ emitter.removeAllListeners();
2170
+ };
2171
+ const handleExternalAbort = () => {
2172
+ logger.info(`Request signal aborted, stopping watcher for [${_docIds}]`);
2173
+ stopWatching();
2174
+ };
2175
+ request?.signal?.addEventListener("abort", handleExternalAbort, { once: true });
1758
2176
  const connect = async () => {
1759
2177
  if (stopping) return;
1760
2178
  const feed = "continuous";
1761
2179
  const includeDocs = options.include_docs ?? false;
1762
- const ids = _docIds.join("\",\"");
1763
- const url = `${config.couch}/_changes?feed=${feed}&since=${lastSeq}&include_docs=${includeDocs}&filter=_doc_ids&doc_ids=["${ids}"]`;
1764
- const mergedOpts = mergeNeedleOpts(config, {
1765
- json: false,
1766
- headers: { "Content-Type": "application/json" },
1767
- parse_response: false
1768
- });
2180
+ const url = createCouchPathUrl("_changes", config.couch);
2181
+ url.searchParams.set("feed", feed);
2182
+ url.searchParams.set("since", String(lastSeq));
2183
+ url.searchParams.set("include_docs", String(includeDocs));
2184
+ url.searchParams.set("filter", "_doc_ids");
2185
+ url.searchParams.set("doc_ids", JSON.stringify(_docIds));
2186
+ const abortController = new AbortController();
2187
+ currentAbortController = abortController;
1769
2188
  let buffer = "";
1770
- currentRequest = needle.default.get(url, mergedOpts);
1771
- currentRequest.on("data", (chunk) => {
1772
- buffer += chunk.toString();
1773
- const lines = buffer.split("\n");
1774
- buffer = lines.pop() || "";
1775
- for (const line of lines) if (line.trim()) try {
2189
+ const processLine = (line) => {
2190
+ if (!line.trim()) return;
2191
+ try {
1776
2192
  const change = JSON.parse(line);
1777
- if (!change.id) return null;
2193
+ if (!change.id) return;
1778
2194
  logger.debug(`Change detected, watching [${_docIds}]`, change);
1779
2195
  lastSeq = change.seq || change.last_seq;
1780
2196
  emitter.emit("change", change);
1781
2197
  } catch (err) {
1782
2198
  logger.error("Error parsing change:", err, "Line:", line);
1783
2199
  }
1784
- });
1785
- currentRequest.on("response", (response) => {
2200
+ };
2201
+ try {
2202
+ const response = await fetchCouchStream({
2203
+ auth: config.auth,
2204
+ method: "GET",
2205
+ operation: "watchDocs",
2206
+ url,
2207
+ headers: { "Content-Type": "application/json" },
2208
+ request,
2209
+ signal: AbortSignal.any([abortController.signal, lifecycleAbortController.signal])
2210
+ });
1786
2211
  logger.debug(`Received response with status code, watching [${_docIds}]: ${response.statusCode}`);
1787
2212
  if (RetryableError.isRetryableStatusCode(response.statusCode)) {
1788
2213
  logger.warn(`Retryable status code received: ${response.statusCode}`);
1789
- currentRequest?.destroy();
1790
- handleReconnect();
1791
- } else retryCount = 0;
1792
- });
1793
- currentRequest.on("error", async (err) => {
1794
- if (stopping) {
2214
+ abortController.abort();
2215
+ await handleReconnect();
2216
+ return;
2217
+ }
2218
+ if (!isSuccessStatusCode("changesFeed", response.statusCode)) {
2219
+ emitter.emit("error", createResponseError({
2220
+ defaultMessage: "Watch request failed",
2221
+ operation: "watchDocs",
2222
+ statusCode: response.statusCode
2223
+ }));
2224
+ return;
2225
+ }
2226
+ retryCount = 0;
2227
+ if (!response.body) throw new RetryableError("Watch request failed", 503, { operation: "watchDocs" });
2228
+ const reader = response.body.getReader();
2229
+ const decoder = new TextDecoder();
2230
+ while (!stopping) {
2231
+ const { done, value } = await reader.read();
2232
+ if (done) break;
2233
+ buffer += decoder.decode(value, { stream: true });
2234
+ const lines = buffer.split("\n");
2235
+ buffer = lines.pop() || "";
2236
+ lines.forEach(processLine);
2237
+ }
2238
+ if (stopping || abortController.signal.aborted) return;
2239
+ buffer += decoder.decode();
2240
+ if (buffer.trim()) processLine(buffer);
2241
+ logger.info("Stream completed. Last seen seq: ", lastSeq);
2242
+ emitter.emit("end", { lastSeq });
2243
+ if (!stopping) await handleReconnect();
2244
+ } catch (err) {
2245
+ if (stopping || abortController.signal.aborted) {
1795
2246
  logger.info("stopping in progress, ignore stream error");
1796
2247
  return;
1797
2248
  }
1798
- logger.error(`Network error during stream, watching [${_docIds}]:`, err.toString());
2249
+ logger.error(`Network error during stream, watching [${_docIds}]:`, String(err));
1799
2250
  try {
1800
- RetryableError.handleNetworkError(err);
2251
+ RetryableError.handleNetworkError(err, "watchDocs");
1801
2252
  } catch (filteredError) {
1802
2253
  if (filteredError instanceof RetryableError) {
1803
2254
  logger.info(`Retryable error, watching [${_docIds}]:`, filteredError.toString());
1804
- handleReconnect();
2255
+ await handleReconnect();
1805
2256
  } else {
1806
- logger.error(`Non-retryable error, watching [${_docIds}]`, filteredError?.toString());
2257
+ logger.error(`Non-retryable error, watching [${_docIds}]`, String(filteredError));
1807
2258
  emitter.emit("error", filteredError);
1808
2259
  }
1809
2260
  }
1810
- });
1811
- currentRequest.on("end", () => {
1812
- if (buffer.trim()) try {
1813
- const change = JSON.parse(buffer);
1814
- logger.debug("Final change detected:", change);
1815
- emitter.emit("change", change);
1816
- } catch (err) {
1817
- logger.error("Error parsing final change:", err);
1818
- }
1819
- logger.info("Stream completed. Last seen seq: ", lastSeq);
1820
- emitter.emit("end", { lastSeq });
1821
- if (!stopping) handleReconnect();
1822
- });
2261
+ }
1823
2262
  };
1824
2263
  const handleReconnect = async () => {
1825
2264
  if (stopping || retryCount >= maxRetries) {
1826
2265
  if (retryCount >= maxRetries) {
1827
2266
  logger.error(`Max retries (${maxRetries}) reached, giving up`);
1828
- emitter.emit("error", /* @__PURE__ */ new Error("Max retries reached"));
2267
+ emitter.emit("error", new OperationError("Watch retries exhausted", { operation: "watchDocs" }));
1829
2268
  }
1830
2269
  return;
1831
2270
  }
1832
2271
  const delay = Math.min(initialDelay * Math.pow(2, retryCount), maxDelay);
1833
2272
  retryCount++;
1834
2273
  logger.info(`Attempting to reconnect in ${delay}ms (attempt ${retryCount} of ${maxRetries})`);
1835
- await (0, node_timers_promises.setTimeout)(delay);
1836
2274
  try {
1837
- connect();
2275
+ await (0, node_timers_promises.setTimeout)(delay, void 0, { signal: lifecycleAbortController.signal });
2276
+ } catch {
2277
+ return;
2278
+ }
2279
+ try {
2280
+ await connect();
1838
2281
  } catch (err) {
1839
2282
  logger.error("Error during reconnection:", err);
1840
- handleReconnect();
2283
+ await handleReconnect();
1841
2284
  }
1842
2285
  };
1843
- connect();
1844
2286
  emitter.on("change", onChange);
2287
+ if (request?.signal?.aborted) stopWatching();
2288
+ else connect();
1845
2289
  return {
1846
2290
  on: (event, listener) => emitter.on(event, listener),
1847
2291
  removeListener: (event, listener) => emitter.removeListener(event, listener),
1848
- stop: () => {
1849
- stopping = true;
1850
- if (currentRequest) currentRequest.destroy();
1851
- emitter.emit("end", { lastSeq });
1852
- emitter.removeAllListeners();
1853
- }
2292
+ stop: stopWatching
1854
2293
  };
1855
2294
  }
1856
2295
 
@@ -1928,7 +2367,13 @@ function doBind(config) {
1928
2367
  }
1929
2368
 
1930
2369
  //#endregion
2370
+ exports.ConflictError = ConflictError;
2371
+ exports.HideABedError = HideABedError;
2372
+ exports.NotFoundError = NotFoundError;
2373
+ exports.OperationError = OperationError;
1931
2374
  exports.QueryBuilder = QueryBuilder;
2375
+ exports.RetryableError = RetryableError;
2376
+ exports.ValidationError = ValidationError;
1932
2377
  exports.bindConfig = bindConfig;
1933
2378
  exports.bulkGet = bulkGet;
1934
2379
  exports.bulkGetDictionary = bulkGetDictionary;