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
@@ -1,6 +1,6 @@
1
1
  import z, { z as z$1 } from "zod";
2
2
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
3
- import needle from "needle";
3
+ import { Readable } from "node:stream";
4
4
  import Chain from "stream-chain";
5
5
  import Parser from "stream-json/Parser.js";
6
6
  import Pick from "stream-json/filters/Pick.js";
@@ -149,6 +149,21 @@ var QueryBuilder = class {
149
149
  };
150
150
  const createQuery = () => new QueryBuilder();
151
151
 
152
+ //#endregion
153
+ //#region schema/request.mts
154
+ const isAbortSignal = (value) => {
155
+ return value instanceof AbortSignal;
156
+ };
157
+ const isDispatcher = (value) => {
158
+ if (typeof value !== "object" || value === null) return false;
159
+ return typeof value.dispatch === "function";
160
+ };
161
+ const RequestOptions = z$1.strictObject({
162
+ dispatcher: z$1.custom(isDispatcher, { message: "dispatcher must expose a dispatch method" }).optional().describe("dispatcher to use for the request"),
163
+ signal: z$1.custom(isAbortSignal, { message: "signal must be an AbortSignal" }).optional().describe("abort signal for the request"),
164
+ timeout: z$1.number().nonnegative().optional().describe("request timeout in milliseconds")
165
+ });
166
+
152
167
  //#endregion
153
168
  //#region schema/config.mts
154
169
  const anyArgs = z$1.array(z$1.any());
@@ -173,56 +188,61 @@ const LoggerSchema = z$1.object({
173
188
  input: anyArgs,
174
189
  output: z$1.void()
175
190
  }));
176
- const NeedleBaseOptions = z$1.object({
177
- json: z$1.boolean(),
178
- headers: z$1.record(z$1.string(), z$1.string()),
179
- parse_response: z$1.boolean().optional()
191
+ const CouchAuth = z$1.strictObject({
192
+ username: z$1.string().describe("basic auth username for CouchDB requests"),
193
+ password: z$1.string().describe("basic auth password for CouchDB requests")
180
194
  });
181
- const NeedleOptions = z$1.object({
182
- json: z$1.boolean().optional(),
183
- compressed: z$1.boolean().optional(),
184
- follow_max: z$1.number().optional(),
185
- follow_set_cookie: z$1.boolean().optional(),
186
- follow_set_referer: z$1.boolean().optional(),
187
- follow: z$1.number().optional(),
188
- timeout: z$1.number().optional(),
189
- read_timeout: z$1.number().optional(),
190
- parse_response: z$1.boolean().optional(),
191
- decode: z$1.boolean().optional(),
192
- parse_cookies: z$1.boolean().optional(),
193
- cookies: z$1.record(z$1.string(), z$1.string()).optional(),
194
- headers: z$1.record(z$1.string(), z$1.string()).optional(),
195
- auth: z$1.enum([
196
- "auto",
197
- "digest",
198
- "basic"
199
- ]).optional(),
200
- username: z$1.string().optional(),
201
- password: z$1.string().optional(),
202
- proxy: z$1.string().optional(),
203
- agent: z$1.any().optional(),
204
- rejectUnauthorized: z$1.boolean().optional(),
205
- output: z$1.string().optional(),
206
- parse: z$1.boolean().optional(),
207
- multipart: z$1.boolean().optional(),
208
- open_timeout: z$1.number().optional(),
209
- response_timeout: z$1.number().optional(),
210
- keepAlive: z$1.boolean().optional()
195
+ const CouchUrl = z$1.custom((value) => {
196
+ try {
197
+ const url = new URL(value);
198
+ return url.username === "" && url.password === "";
199
+ } catch {
200
+ return false;
201
+ }
211
202
  });
212
203
  const CouchConfig = z$1.strictObject({
204
+ auth: CouchAuth.optional().describe("basic auth credentials for CouchDB requests"),
213
205
  backoffFactor: z$1.number().optional().default(2).describe("multiplier for exponential backoff"),
214
206
  bindWithRetry: z$1.boolean().optional().default(true).describe("should we bind with retry"),
215
- couch: z$1.string().describe("the url of the couch db"),
207
+ couch: CouchUrl.describe("URL of the couch db without embedded credentials"),
216
208
  initialDelay: z$1.number().optional().default(1e3).describe("initial retry delay in milliseconds"),
217
209
  logger: LoggerSchema.optional().describe("logging interface supporting winston-like or simple function interface"),
218
210
  maxRetries: z$1.number().optional().default(3).describe("maximum number of retry attempts"),
219
- needleOpts: NeedleOptions.optional(),
220
- throwOnGetNotFound: z$1.boolean().optional().default(false).describe("if a get is 404 should we throw or return undefined"),
211
+ request: RequestOptions.optional().describe("default request controls for CouchDB requests"),
212
+ throwOnGetNotFound: z$1.boolean().optional().default(false).describe("if true, get() throws NotFoundError on 404; otherwise it returns null"),
221
213
  useConsoleLogger: z$1.boolean().optional().default(false).describe("turn on console as a fallback logger"),
222
214
  "~emitter": z$1.any().optional().describe("emitter for events"),
223
215
  "~normalizedLogger": z$1.any().optional()
224
216
  }).describe("The std config object");
225
217
 
218
+ //#endregion
219
+ //#region impl/utils/response.mts
220
+ const isRecord = (value) => {
221
+ return value !== null && typeof value === "object";
222
+ };
223
+ const SUCCESS_STATUS_CODES = {
224
+ bulkGet: [200],
225
+ bulkSave: [201, 202],
226
+ changesFeed: [200],
227
+ database: [200],
228
+ documentDelete: [200, 202],
229
+ documentRead: [200],
230
+ documentWrite: [
231
+ 200,
232
+ 201,
233
+ 202
234
+ ],
235
+ viewQuery: [200],
236
+ viewStream: [200]
237
+ };
238
+ const getCouchError = (value) => {
239
+ if (!isRecord(value) || typeof value.error !== "string") return;
240
+ return value.error;
241
+ };
242
+ const isSuccessStatusCode = (profile, statusCode) => {
243
+ return SUCCESS_STATUS_CODES[profile].includes(statusCode);
244
+ };
245
+
226
246
  //#endregion
227
247
  //#region impl/utils/errors.mts
228
248
  const RETRYABLE_STATUS_CODES = new Set([
@@ -248,6 +268,35 @@ const isNetworkError = (value) => {
248
268
  const candidate = value;
249
269
  return typeof candidate.code === "string" && candidate.code in NETWORK_ERROR_STATUS_MAP;
250
270
  };
271
+ const getNestedNetworkError = (value) => {
272
+ if (isNetworkError(value)) return value;
273
+ if (typeof value !== "object" || value === null) return null;
274
+ const candidate = value;
275
+ return isNetworkError(candidate.cause) ? candidate.cause : null;
276
+ };
277
+ /**
278
+ * Shared base class for operational errors thrown by hide-a-bed.
279
+ *
280
+ * @public
281
+ */
282
+ var HideABedError = class extends Error {
283
+ category;
284
+ couchError;
285
+ docId;
286
+ operation;
287
+ retryable;
288
+ statusCode;
289
+ constructor(message, options) {
290
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
291
+ this.name = "HideABedError";
292
+ this.category = options.category;
293
+ this.couchError = options.couchError;
294
+ this.docId = options.docId;
295
+ this.operation = options.operation;
296
+ this.retryable = options.retryable;
297
+ this.statusCode = options.statusCode;
298
+ }
299
+ };
251
300
  /**
252
301
  * Error thrown when a requested CouchDB document cannot be found.
253
302
  *
@@ -257,21 +306,77 @@ const isNetworkError = (value) => {
257
306
  *
258
307
  * @public
259
308
  */
260
- var NotFoundError = class extends Error {
261
- /**
262
- * Identifier of the missing document.
263
- */
264
- docId;
265
- /**
266
- * Creates a new {@link NotFoundError} instance.
267
- *
268
- * @param docId - The identifier of the document that was not found.
269
- * @param message - Optional custom error message.
270
- */
271
- constructor(docId, message = "Document not found") {
272
- super(message);
309
+ var NotFoundError = class extends HideABedError {
310
+ constructor(docId, options = {}) {
311
+ super(options.message ?? "Document not found", {
312
+ category: "not_found",
313
+ couchError: options.couchError ?? "not_found",
314
+ cause: options.cause,
315
+ docId,
316
+ operation: options.operation,
317
+ retryable: false,
318
+ statusCode: options.statusCode ?? 404
319
+ });
273
320
  this.name = "NotFoundError";
274
- this.docId = docId;
321
+ }
322
+ };
323
+ /**
324
+ * Error thrown when a single-document mutation conflicts with the current revision.
325
+ *
326
+ * @public
327
+ */
328
+ var ConflictError = class extends HideABedError {
329
+ constructor(docId, options = {}) {
330
+ super(options.message ?? "Document update conflict", {
331
+ category: "conflict",
332
+ couchError: options.couchError ?? "conflict",
333
+ cause: options.cause,
334
+ docId,
335
+ operation: options.operation,
336
+ retryable: false,
337
+ statusCode: options.statusCode ?? 409
338
+ });
339
+ this.name = "ConflictError";
340
+ }
341
+ };
342
+ /**
343
+ * Error thrown when an operation fails in a non-retryable way.
344
+ *
345
+ * @public
346
+ */
347
+ var OperationError = class extends HideABedError {
348
+ constructor(message, options = {}) {
349
+ super(message, {
350
+ category: options.category ?? "operation",
351
+ cause: options.cause,
352
+ couchError: options.couchError,
353
+ docId: options.docId,
354
+ operation: options.operation,
355
+ retryable: false,
356
+ statusCode: options.statusCode
357
+ });
358
+ this.name = "OperationError";
359
+ }
360
+ };
361
+ /**
362
+ * Error thrown when schema validation fails for a document, row, key, or value.
363
+ *
364
+ * @public
365
+ */
366
+ var ValidationError = class extends HideABedError {
367
+ issues;
368
+ constructor(options) {
369
+ super(options.message ?? "Validation failed", {
370
+ category: "validation",
371
+ cause: options.cause,
372
+ couchError: options.couchError,
373
+ docId: options.docId,
374
+ operation: options.operation,
375
+ retryable: false,
376
+ statusCode: options.statusCode
377
+ });
378
+ this.name = "ValidationError";
379
+ this.issues = options.issues;
275
380
  }
276
381
  };
277
382
  /**
@@ -283,21 +388,18 @@ var NotFoundError = class extends Error {
283
388
  *
284
389
  * @public
285
390
  */
286
- var RetryableError = class RetryableError extends Error {
287
- /**
288
- * HTTP status code associated with the retryable failure, when available.
289
- */
290
- statusCode;
291
- /**
292
- * Creates a new {@link RetryableError} instance.
293
- *
294
- * @param message - Detailed description of the failure.
295
- * @param statusCode - Optional HTTP status code corresponding to the failure.
296
- */
297
- constructor(message, statusCode) {
298
- super(message);
391
+ var RetryableError = class RetryableError extends HideABedError {
392
+ constructor(message, statusCode, options = {}) {
393
+ super(message, {
394
+ category: options.category ?? "retryable",
395
+ cause: options.cause,
396
+ couchError: options.couchError,
397
+ docId: options.docId,
398
+ operation: options.operation,
399
+ retryable: true,
400
+ statusCode
401
+ });
299
402
  this.name = "RetryableError";
300
- this.statusCode = statusCode;
301
403
  }
302
404
  /**
303
405
  * Determines whether the provided status code should be treated as retryable.
@@ -318,15 +420,45 @@ var RetryableError = class RetryableError extends Error {
318
420
  * @throws {@link RetryableError} When the error maps to a retryable network condition.
319
421
  * @throws {*} Re-throws the original error when it cannot be mapped.
320
422
  */
321
- static handleNetworkError(err) {
322
- if (isNetworkError(err)) {
323
- const statusCode = NETWORK_ERROR_STATUS_MAP[err.code];
324
- if (statusCode) throw new RetryableError(`Network error: ${err.code}`, statusCode);
423
+ static handleNetworkError(err, operation = "request") {
424
+ const networkError = getNestedNetworkError(err);
425
+ if (networkError) {
426
+ const statusCode = NETWORK_ERROR_STATUS_MAP[networkError.code];
427
+ if (statusCode) throw new RetryableError("Network request failed", statusCode, {
428
+ category: "network",
429
+ cause: err,
430
+ operation
431
+ });
325
432
  }
326
433
  throw err;
327
434
  }
328
435
  };
436
+ function createResponseError({ body, defaultMessage, docId, notFoundMessage, operation, statusCode }) {
437
+ const couchError = getCouchError(body);
438
+ if (statusCode === 404 && docId) return new NotFoundError(docId, {
439
+ couchError,
440
+ message: notFoundMessage,
441
+ operation,
442
+ statusCode
443
+ });
444
+ if (statusCode === 409 && docId) return new ConflictError(docId, {
445
+ couchError,
446
+ operation,
447
+ statusCode
448
+ });
449
+ if (RetryableError.isRetryableStatusCode(statusCode)) return new RetryableError(defaultMessage, statusCode, {
450
+ couchError,
451
+ operation
452
+ });
453
+ return new OperationError(defaultMessage, {
454
+ couchError,
455
+ docId,
456
+ operation,
457
+ statusCode
458
+ });
459
+ }
329
460
  function isConflictError(err) {
461
+ if (err instanceof ConflictError) return true;
330
462
  if (typeof err !== "object" || err === null) return false;
331
463
  return err.statusCode === 409;
332
464
  }
@@ -400,27 +532,6 @@ function createLogger(config) {
400
532
  return normalized;
401
533
  }
402
534
 
403
- //#endregion
404
- //#region schema/util.mts
405
- const MergeNeedleOpts = z$1.function({
406
- input: [CouchConfig, NeedleBaseOptions],
407
- output: NeedleOptions
408
- });
409
-
410
- //#endregion
411
- //#region impl/utils/mergeNeedleOpts.mts
412
- const mergeNeedleOpts = MergeNeedleOpts.implement((config, opts) => {
413
- if (config.needleOpts) return {
414
- ...opts,
415
- ...config.needleOpts,
416
- headers: {
417
- ...opts.headers,
418
- ...config.needleOpts.headers ?? {}
419
- }
420
- };
421
- return opts;
422
- });
423
-
424
535
  //#endregion
425
536
  //#region schema/couch/couch.output.schema.ts
426
537
  /**
@@ -493,8 +604,16 @@ const CouchDBInfo = z$1.looseObject({
493
604
 
494
605
  //#endregion
495
606
  //#region impl/utils/parseRows.mts
607
+ const createValidationError = (issues, options) => {
608
+ return new ValidationError({
609
+ docId: options.docId,
610
+ issues,
611
+ message: options.defaultMessage ?? "Row validation failed",
612
+ operation: options.operation ?? "request"
613
+ });
614
+ };
496
615
  async function parseRows(rows, options) {
497
- if (!Array.isArray(rows)) throw new Error("invalid rows format");
616
+ if (!Array.isArray(rows)) throw new OperationError(options.defaultMessage ?? "Request failed", { operation: options.operation ?? "request" });
498
617
  const isFinalRow = (row) => row !== "skip";
499
618
  return (await Promise.all(rows.map(async (row) => {
500
619
  try {
@@ -506,12 +625,20 @@ async function parseRows(rows, options) {
506
625
  const parsedRow = z$1.looseObject(ViewRow.shape).parse(row);
507
626
  if (options.keySchema) {
508
627
  const parsedKey$1 = await options.keySchema["~standard"].validate(row.key);
509
- if (parsedKey$1.issues) throw parsedKey$1.issues;
628
+ if (parsedKey$1.issues) throw createValidationError(parsedKey$1.issues, {
629
+ defaultMessage: options.defaultMessage,
630
+ docId: typeof row.id === "string" ? row.id : void 0,
631
+ operation: options.operation
632
+ });
510
633
  parsedRow.key = parsedKey$1.value;
511
634
  }
512
635
  if (options.valueSchema) {
513
636
  const parsedValue$1 = await options.valueSchema["~standard"].validate(row.value);
514
- if (parsedValue$1.issues) throw parsedValue$1.issues;
637
+ if (parsedValue$1.issues) throw createValidationError(parsedValue$1.issues, {
638
+ defaultMessage: options.defaultMessage,
639
+ docId: typeof row.id === "string" ? row.id : void 0,
640
+ operation: options.operation
641
+ });
515
642
  parsedRow.value = parsedValue$1.value;
516
643
  }
517
644
  return parsedRow;
@@ -522,17 +649,29 @@ async function parseRows(rows, options) {
522
649
  if (options.docSchema) {
523
650
  const parsedDocRes = await options.docSchema["~standard"].validate(row.doc);
524
651
  if (parsedDocRes.issues) if (options.onInvalidDoc === "skip") return "skip";
525
- else throw parsedDocRes.issues;
652
+ else throw createValidationError(parsedDocRes.issues, {
653
+ defaultMessage: options.defaultMessage,
654
+ docId: typeof row.id === "string" ? row.id : void 0,
655
+ operation: options.operation
656
+ });
526
657
  else parsedDoc = parsedDocRes.value;
527
658
  }
528
659
  if (options.keySchema) {
529
660
  const parsedKeyRes = await options.keySchema["~standard"].validate(row.key);
530
- if (parsedKeyRes.issues) throw parsedKeyRes.issues;
661
+ if (parsedKeyRes.issues) throw createValidationError(parsedKeyRes.issues, {
662
+ defaultMessage: options.defaultMessage,
663
+ docId: typeof row.id === "string" ? row.id : void 0,
664
+ operation: options.operation
665
+ });
531
666
  else parsedKey = parsedKeyRes.value;
532
667
  }
533
668
  if (options.valueSchema) {
534
669
  const parsedValueRes = await options.valueSchema["~standard"].validate(row.value);
535
- if (parsedValueRes.issues) throw parsedValueRes.issues;
670
+ if (parsedValueRes.issues) throw createValidationError(parsedValueRes.issues, {
671
+ defaultMessage: options.defaultMessage,
672
+ docId: typeof row.id === "string" ? row.id : void 0,
673
+ operation: options.operation
674
+ });
536
675
  else parsedValue = parsedValueRes.value;
537
676
  }
538
677
  return {
@@ -548,6 +687,137 @@ async function parseRows(rows, options) {
548
687
  }))).filter(isFinalRow);
549
688
  }
550
689
 
690
+ //#endregion
691
+ //#region impl/utils/request.mts
692
+ const definedSignals = (signals) => {
693
+ return signals.filter((signal) => signal != null);
694
+ };
695
+ const composeAbortSignal = (internalSignal, request) => {
696
+ const timeoutSignal = typeof request?.timeout === "number" ? AbortSignal.timeout(request.timeout) : void 0;
697
+ const signals = definedSignals([
698
+ internalSignal,
699
+ request?.signal,
700
+ timeoutSignal
701
+ ]);
702
+ return {
703
+ signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],
704
+ timedOut: () => timeoutSignal?.aborted === true
705
+ };
706
+ };
707
+
708
+ //#endregion
709
+ //#region impl/utils/fetch.mts
710
+ const JSON_HEADERS = { "Content-Type": "application/json" };
711
+ const hasHeader = (headers, name) => {
712
+ const expected = name.toLowerCase();
713
+ return Object.keys(headers).some((header) => header.toLowerCase() === expected);
714
+ };
715
+ const toBasicAuthHeader = ({ username, password }) => {
716
+ return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
717
+ };
718
+ const prepareRequest = (options) => {
719
+ const auth = options.auth;
720
+ const headers = { ...options.headers ?? {} };
721
+ if (auth && !hasHeader(headers, "Authorization")) headers.Authorization = toBasicAuthHeader(auth);
722
+ return { headers };
723
+ };
724
+ const isAbortError = (err) => {
725
+ return err instanceof DOMException && err.name === "AbortError";
726
+ };
727
+ const isTimeoutError = (err) => {
728
+ return err instanceof DOMException && err.name === "TimeoutError";
729
+ };
730
+ const encodeBody = (body) => {
731
+ if (body == null) return void 0;
732
+ if (typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof Blob || body instanceof FormData || body instanceof URLSearchParams || body instanceof ReadableStream) return body;
733
+ return JSON.stringify(body);
734
+ };
735
+ const parseJsonResponse = async (response) => {
736
+ if (response.status === 204 || response.status === 205) return null;
737
+ const text = await response.text();
738
+ if (text.trim() === "") return null;
739
+ try {
740
+ return JSON.parse(text);
741
+ } catch (err) {
742
+ if (response.ok) throw err;
743
+ return text;
744
+ }
745
+ };
746
+ async function fetchCouchJson(options) {
747
+ let response;
748
+ const { headers } = prepareRequest(options);
749
+ const { signal, timedOut } = composeAbortSignal(options.signal, options.request);
750
+ try {
751
+ response = await fetch(options.url, {
752
+ method: options.method,
753
+ headers: {
754
+ ...JSON_HEADERS,
755
+ ...headers
756
+ },
757
+ body: encodeBody(options.body),
758
+ signal,
759
+ dispatcher: options.request?.dispatcher
760
+ });
761
+ } catch (err) {
762
+ if (timedOut() || isTimeoutError(err)) throw new RetryableError("Request timed out", 503, {
763
+ category: "network",
764
+ cause: err,
765
+ operation: options.operation
766
+ });
767
+ if (isAbortError(err)) throw err;
768
+ RetryableError.handleNetworkError(err, options.operation);
769
+ }
770
+ return {
771
+ body: await parseJsonResponse(response),
772
+ headers: response.headers,
773
+ statusCode: response.status
774
+ };
775
+ }
776
+ async function fetchCouchStream(options) {
777
+ let response;
778
+ const { headers } = prepareRequest(options);
779
+ const { signal, timedOut } = composeAbortSignal(options.signal, options.request);
780
+ try {
781
+ response = await fetch(options.url, {
782
+ method: options.method,
783
+ headers,
784
+ body: encodeBody(options.body),
785
+ signal,
786
+ dispatcher: options.request?.dispatcher
787
+ });
788
+ } catch (err) {
789
+ if (timedOut() || isTimeoutError(err)) throw new RetryableError("Request timed out", 503, {
790
+ category: "network",
791
+ cause: err,
792
+ operation: options.operation
793
+ });
794
+ if (isAbortError(err)) throw err;
795
+ RetryableError.handleNetworkError(err, options.operation);
796
+ }
797
+ return {
798
+ body: response.body,
799
+ headers: response.headers,
800
+ statusCode: response.status
801
+ };
802
+ }
803
+
804
+ //#endregion
805
+ //#region impl/utils/url.mts
806
+ const ensureDirectoryUrl = (value) => {
807
+ const url = new URL(value);
808
+ if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
809
+ return url;
810
+ };
811
+ const createCouchDbUrl = (value) => {
812
+ return new URL(value);
813
+ };
814
+ const createCouchPathUrl = (path, base) => {
815
+ return new URL(path, ensureDirectoryUrl(base));
816
+ };
817
+ const createCouchDocUrl = (docId, base) => {
818
+ return new URL(encodeURIComponent(docId), ensureDirectoryUrl(base));
819
+ };
820
+
551
821
  //#endregion
552
822
  //#region impl/bulkGet.mts
553
823
  /**
@@ -560,7 +830,7 @@ async function parseRows(rows, options) {
560
830
  * @returns The raw response body from CouchDB
561
831
  *
562
832
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
563
- * @throws {Error} When CouchDB returns a non-retryable error payload.
833
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
564
834
  */
565
835
  async function executeBulkGet(_config, ids, includeDocs) {
566
836
  const configParseResult = CouchConfig.safeParse(_config);
@@ -571,26 +841,35 @@ async function executeBulkGet(_config, ids, includeDocs) {
571
841
  throw configParseResult.error;
572
842
  }
573
843
  const config = configParseResult.data;
574
- const url = `${config.couch}/_all_docs${includeDocs ? "?include_docs=true" : ""}`;
844
+ const url = createCouchPathUrl("_all_docs", config.couch);
845
+ if (includeDocs) url.searchParams.append("include_docs", "true");
575
846
  const payload = { keys: ids };
576
- const mergedOpts = mergeNeedleOpts(config, {
577
- json: true,
578
- headers: { "Content-Type": "application/json" }
579
- });
580
847
  try {
581
- const resp = await needle("post", url, payload, mergedOpts);
848
+ const resp = await fetchCouchJson({
849
+ auth: config.auth,
850
+ method: "POST",
851
+ operation: "request",
852
+ request: config.request,
853
+ url,
854
+ body: payload
855
+ });
582
856
  if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
583
857
  logger.warn(`Retryable status code received: ${resp.statusCode}`);
584
- throw new RetryableError("retryable error during bulk get", resp.statusCode);
858
+ throw new RetryableError("Bulk get failed", resp.statusCode, { operation: "request" });
585
859
  }
586
- if (resp.statusCode !== 200) {
860
+ if (!isSuccessStatusCode("bulkGet", resp.statusCode)) {
587
861
  logger.error(`Unexpected status code: ${resp.statusCode}`);
588
- throw new Error("could not fetch");
862
+ throw createResponseError({
863
+ body: resp.body,
864
+ defaultMessage: "Bulk get failed",
865
+ operation: "request",
866
+ statusCode: resp.statusCode
867
+ });
589
868
  }
590
869
  return resp.body;
591
870
  } catch (err) {
592
871
  logger.error("Network error during bulk get:", err);
593
- RetryableError.handleNetworkError(err);
872
+ RetryableError.handleNetworkError(err, "request");
594
873
  }
595
874
  }
596
875
  /**
@@ -605,16 +884,22 @@ async function executeBulkGet(_config, ids, includeDocs) {
605
884
  * @returns The bulk get response with rows optionally validated against the supplied document schema.
606
885
  *
607
886
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
608
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
609
- * @throws {Error} When CouchDB returns a non-retryable error payload.
887
+ * @throws {ValidationError} When returned documents fail schema validation.
888
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
610
889
  */
611
890
  async function _bulkGetWithOptions(config, ids, options = {}) {
612
891
  const body = await executeBulkGet(config, ids, options.includeDocs ?? true);
613
- if (!body) throw new RetryableError("no response", 503);
614
- if (body.error) throw new Error(typeof body.reason === "string" ? body.reason : "could not fetch");
892
+ if (!body) throw new RetryableError("Bulk get failed", 503, { operation: "request" });
893
+ if (body.error) throw createResponseError({
894
+ body,
895
+ defaultMessage: "Bulk get failed",
896
+ operation: "request"
897
+ });
615
898
  const docSchema = options.validate?.docSchema || CouchDoc;
616
899
  const rows = await parseRows(body.rows, {
900
+ defaultMessage: "Bulk get failed",
617
901
  onInvalidDoc: options.validate?.onInvalidDoc,
902
+ operation: "request",
618
903
  docSchema
619
904
  });
620
905
  return {
@@ -640,8 +925,8 @@ async function _bulkGetWithOptions(config, ids, options = {}) {
640
925
  * @returns The bulk get response with rows optionally validated against the supplied document schema.
641
926
  *
642
927
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
643
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
644
- * @throws {Error} When CouchDB returns a non-retryable error payload.
928
+ * @throws {ValidationError} When returned documents fail schema validation.
929
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
645
930
  */
646
931
  async function bulkGet(config, ids, options = {}) {
647
932
  return _bulkGetWithOptions(config, ids, {
@@ -652,7 +937,7 @@ async function bulkGet(config, ids, options = {}) {
652
937
  /**
653
938
  * Bulk get documents by IDs and return a dictionary of found and not found documents.
654
939
  *
655
- * @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"]>.
940
+ * @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.
656
941
  *
657
942
  * @param config - CouchDB configuration data that is validated before use.
658
943
  * @param ids - Array of document IDs to retrieve.
@@ -661,8 +946,8 @@ async function bulkGet(config, ids, options = {}) {
661
946
  * @returns An object containing found documents and not found rows.
662
947
  *
663
948
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
664
- * @throws {Error<StandardSchemaV1.FailureResult["issues"]>} When the configuration or validation schemas fail to parse.
665
- * @throws {Error} When CouchDB returns a non-retryable error payload.
949
+ * @throws {ValidationError} When returned documents fail schema validation.
950
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
666
951
  */
667
952
  async function bulkGetDictionary(config, ids, options) {
668
953
  const response = await bulkGet(config, ids, {
@@ -693,60 +978,65 @@ async function bulkGetDictionary(config, ids, options) {
693
978
 
694
979
  //#endregion
695
980
  //#region impl/get.mts
696
- const ValidSchema = z$1.custom((value) => {
981
+ const ValidSchema$1 = z$1.custom((value) => {
697
982
  return value !== null && typeof value === "object" && "~standard" in value;
698
983
  }, { message: "docSchema must be a valid StandardSchemaV1 schema" });
699
- const CouchGetOptions = z$1.object({
984
+ const CouchGetOptions = z$1.strictObject({
700
985
  rev: z$1.string().optional().describe("the couch doc revision"),
701
- validate: z$1.object({ docSchema: ValidSchema.optional() }).optional().describe("optional document validation rules")
986
+ validate: z$1.object({ docSchema: ValidSchema$1.optional() }).optional().describe("optional document validation rules")
702
987
  });
703
- async function _getWithOptions(config, id, options) {
704
- const parsedOptions = CouchGetOptions.parse({
705
- rev: options.rev,
706
- validate: options.validate
707
- });
988
+ async function _getWithOptions(configInput, id, options) {
989
+ const config = CouchConfig.parse(configInput);
990
+ const parsedOptions = CouchGetOptions.parse(options);
708
991
  const logger = createLogger(config);
709
992
  const rev = parsedOptions.rev;
710
- const path = rev ? `${id}?rev=${rev}` : id;
711
- const url = `${config.couch}/${path}`;
712
- const requestOptions = mergeNeedleOpts(config, {
713
- json: true,
714
- headers: { "Content-Type": "application/json" }
715
- });
993
+ const operation = rev ? "getAtRev" : "get";
994
+ const url = createCouchDocUrl(id, config.couch);
995
+ if (rev) url.searchParams.set("rev", rev);
716
996
  logger.info(`Getting document with id: ${id}, rev ${rev ?? "latest"}`);
717
997
  try {
718
- const resp = await needle("get", url, null, requestOptions);
998
+ const resp = await fetchCouchJson({
999
+ auth: config.auth,
1000
+ method: "GET",
1001
+ operation,
1002
+ request: config.request,
1003
+ url
1004
+ });
719
1005
  if (!resp) {
720
1006
  logger.error("No response received from get request");
721
- throw new RetryableError("no response", 503);
1007
+ throw new RetryableError("Request failed", 503, { operation });
722
1008
  }
723
1009
  const body = resp.body ?? null;
724
1010
  if (resp.statusCode === 404) {
725
- if (config.throwOnGetNotFound) {
726
- const reason = typeof body?.reason === "string" ? body.reason : "not_found";
727
- logger.warn(`Document not found (throwing error): ${id}, rev ${rev ?? "latest"}`);
728
- throw new NotFoundError(id, reason);
729
- }
730
- logger.debug(`Document not found (returning undefined): ${id}, rev ${rev ?? "latest"}`);
731
- return null;
1011
+ logger.warn(`Document not found: ${id}, rev ${rev ?? "latest"}`);
1012
+ if (config.throwOnGetNotFound === false) return null;
1013
+ throw new NotFoundError(id, {
1014
+ operation,
1015
+ statusCode: resp.statusCode
1016
+ });
732
1017
  }
733
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
734
- const reason = typeof body?.reason === "string" ? body.reason : "retryable error";
735
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
736
- throw new RetryableError(reason, resp.statusCode);
737
- }
738
- if (resp.statusCode !== 200) {
739
- const reason = typeof body?.reason === "string" ? body.reason : "failed";
1018
+ if (!isSuccessStatusCode("documentRead", resp.statusCode)) {
740
1019
  logger.error(`Unexpected status code: ${resp.statusCode}`);
741
- throw new Error(reason);
1020
+ throw createResponseError({
1021
+ body,
1022
+ defaultMessage: "Failed to fetch document",
1023
+ docId: id,
1024
+ operation,
1025
+ statusCode: resp.statusCode
1026
+ });
742
1027
  }
743
1028
  const typedDoc = await (parsedOptions.validate?.docSchema ?? CouchDoc)["~standard"].validate(body);
744
- if (typedDoc.issues) throw typedDoc.issues;
1029
+ if (typedDoc.issues) throw new ValidationError({
1030
+ docId: id,
1031
+ issues: typedDoc.issues,
1032
+ message: "Document validation failed",
1033
+ operation
1034
+ });
745
1035
  logger.info(`Successfully retrieved document: ${id}, rev ${rev ?? "latest"}`);
746
1036
  return typedDoc.value;
747
1037
  } catch (err) {
748
1038
  logger.error("Error during get operation:", err);
749
- RetryableError.handleNetworkError(err);
1039
+ RetryableError.handleNetworkError(err, operation);
750
1040
  }
751
1041
  }
752
1042
  async function get(config, id, options) {
@@ -834,89 +1124,137 @@ function queryString(options = {}) {
834
1124
  */
835
1125
  async function queryStream(rawConfig, view, options, onRow) {
836
1126
  return new Promise((resolve, reject) => {
837
- const config = CouchConfig.parse(rawConfig);
838
- const logger = createLogger(config);
839
- logger.info(`Starting view query stream: ${view}`);
840
- logger.debug("Query options:", options);
841
- const queryOptions = options ?? {};
842
- let method = "GET";
843
- let payload = null;
844
- let qs = queryString(queryOptions);
845
- logger.debug("Generated query string:", qs);
846
- if (typeof queryOptions.keys !== "undefined") {
847
- const MAX_URL_LENGTH = 2e3;
848
- const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`;
849
- if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) qs += (qs.length > 0 ? "&" : "") + keysAsString;
850
- else {
851
- method = "POST";
852
- payload = { keys: queryOptions.keys };
853
- }
854
- }
855
- const url = `${config.couch}/${view}?${qs}`;
856
- const mergedOpts = mergeNeedleOpts(config, {
857
- json: true,
858
- headers: { "Content-Type": "application/json" },
859
- parse_response: false
860
- });
861
- const parserPipeline = Chain.chain([
862
- new Parser(),
863
- new Pick({ filter: "rows" }),
864
- new StreamArray()
865
- ]);
866
- let rowCount = 0;
867
- let settled = false;
868
- const settleReject = (err) => {
869
- if (settled) return;
870
- settled = true;
871
- reject(err);
872
- };
873
- const settleResolve = () => {
874
- if (settled) return;
875
- settled = true;
876
- resolve();
877
- };
878
- let request = null;
879
- parserPipeline.on("data", (chunk) => {
880
- try {
881
- rowCount++;
882
- onRow(chunk.value);
883
- } catch (callbackErr) {
884
- const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr));
885
- parserPipeline.destroy(error);
886
- settleReject(error);
887
- }
888
- });
889
- parserPipeline.on("error", (err) => {
890
- logger.error("Stream parsing error:", err);
891
- parserPipeline.destroy();
892
- settleReject(new Error(`Stream parsing error: ${err.message}`, { cause: err }));
893
- });
894
- parserPipeline.on("end", () => {
895
- logger.info(`Stream completed, processed ${rowCount} rows`);
896
- settleResolve();
897
- });
898
- request = method === "GET" ? needle.get(url, mergedOpts) : needle.post(url, payload, mergedOpts);
899
- request.on("response", (response) => {
900
- logger.debug(`Received response with status code: ${response.statusCode}`);
901
- if (RetryableError.isRetryableStatusCode(response.statusCode)) {
902
- logger.warn(`Retryable status code received: ${response.statusCode}`);
903
- settleReject(new RetryableError("retryable error during stream query", response.statusCode));
904
- request.destroy();
1127
+ (async () => {
1128
+ const config = CouchConfig.parse(rawConfig);
1129
+ const logger = createLogger(config);
1130
+ logger.info(`Starting view query stream: ${view}`);
1131
+ const queryOptions = ViewOptions.parse(options ?? {});
1132
+ const request = config.request;
1133
+ logger.debug("Query options:", {
1134
+ ...queryOptions,
1135
+ request
1136
+ });
1137
+ let method = "GET";
1138
+ let payload = null;
1139
+ let qs = queryString(queryOptions);
1140
+ logger.debug("Generated query string:", qs);
1141
+ if (typeof queryOptions.keys !== "undefined") {
1142
+ const MAX_URL_LENGTH = 2e3;
1143
+ const keysAsString = `keys=${encodeURIComponent(JSON.stringify(queryOptions.keys))}`;
1144
+ if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) qs += (qs.length > 0 ? "&" : "") + keysAsString;
1145
+ else {
1146
+ method = "POST";
1147
+ payload = { keys: queryOptions.keys };
1148
+ }
905
1149
  }
906
- });
907
- request.on("error", (err) => {
908
- logger.error("Network error during stream query:", err);
909
- parserPipeline.destroy(err);
1150
+ const url = createCouchPathUrl(view, config.couch);
1151
+ if (qs) url.search = qs;
1152
+ const requestHeaders = { "Content-Type": "application/json" };
1153
+ const abortController = new AbortController();
1154
+ const requestAbortHandler = () => {
1155
+ const reason = request?.signal?.reason instanceof Error ? request.signal.reason : new DOMException("The operation was aborted.", "AbortError");
1156
+ abortController.abort(reason);
1157
+ responseStream?.destroy(reason);
1158
+ parserPipeline.destroy(reason);
1159
+ settleReject(reason);
1160
+ };
1161
+ const parserPipeline = Chain.chain([
1162
+ new Parser(),
1163
+ new Pick({ filter: "rows" }),
1164
+ new StreamArray()
1165
+ ]);
1166
+ let rowCount = 0;
1167
+ let settled = false;
1168
+ const settleReject = (err) => {
1169
+ if (settled) return;
1170
+ settled = true;
1171
+ request?.signal?.removeEventListener("abort", requestAbortHandler);
1172
+ reject(err);
1173
+ };
1174
+ const settleResolve = () => {
1175
+ if (settled) return;
1176
+ settled = true;
1177
+ request?.signal?.removeEventListener("abort", requestAbortHandler);
1178
+ resolve();
1179
+ };
1180
+ let responseStream = null;
1181
+ request?.signal?.addEventListener("abort", requestAbortHandler, { once: true });
1182
+ parserPipeline.on("data", (chunk) => {
1183
+ try {
1184
+ rowCount++;
1185
+ onRow(chunk.value);
1186
+ } catch (callbackErr) {
1187
+ const error = callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr));
1188
+ parserPipeline.destroy(error);
1189
+ settleReject(error);
1190
+ }
1191
+ });
1192
+ parserPipeline.on("error", (err) => {
1193
+ logger.error("Stream parsing error:", err);
1194
+ parserPipeline.destroy();
1195
+ settleReject(new OperationError("Stream parsing failed", {
1196
+ cause: err,
1197
+ operation: "queryStream"
1198
+ }));
1199
+ });
1200
+ parserPipeline.on("end", () => {
1201
+ logger.info(`Stream completed, processed ${rowCount} rows`);
1202
+ settleResolve();
1203
+ });
910
1204
  try {
911
- RetryableError.handleNetworkError(err);
912
- } catch (retryErr) {
913
- settleReject(retryErr);
914
- return;
915
- } finally {
916
- settleReject(err);
1205
+ const response = await fetchCouchStream({
1206
+ auth: config.auth,
1207
+ method,
1208
+ operation: "queryStream",
1209
+ url,
1210
+ body: method === "POST" ? payload : void 0,
1211
+ headers: requestHeaders,
1212
+ request,
1213
+ signal: abortController.signal
1214
+ });
1215
+ logger.debug(`Received response with status code: ${response.statusCode}`);
1216
+ if (RetryableError.isRetryableStatusCode(response.statusCode)) {
1217
+ logger.warn(`Retryable status code received: ${response.statusCode}`);
1218
+ abortController.abort();
1219
+ settleReject(new RetryableError("Stream query failed", response.statusCode, { operation: "queryStream" }));
1220
+ return;
1221
+ }
1222
+ if (!isSuccessStatusCode("viewStream", response.statusCode)) {
1223
+ abortController.abort();
1224
+ settleReject(createResponseError({
1225
+ defaultMessage: "Stream query failed",
1226
+ operation: "queryStream",
1227
+ statusCode: response.statusCode
1228
+ }));
1229
+ return;
1230
+ }
1231
+ if (!response.body) {
1232
+ settleReject(new RetryableError("Stream query failed", 503, { operation: "queryStream" }));
1233
+ return;
1234
+ }
1235
+ responseStream = Readable.fromWeb(response.body);
1236
+ responseStream.on("error", (err) => {
1237
+ logger.error("Network error during stream query:", err);
1238
+ parserPipeline.destroy(err);
1239
+ try {
1240
+ RetryableError.handleNetworkError(err, "queryStream");
1241
+ } catch (retryErr) {
1242
+ settleReject(retryErr);
1243
+ return;
1244
+ }
1245
+ });
1246
+ responseStream.pipe(parserPipeline);
1247
+ } catch (err) {
1248
+ logger.error("Network error during stream query:", err);
1249
+ parserPipeline.destroy(err);
1250
+ try {
1251
+ RetryableError.handleNetworkError(err, "queryStream");
1252
+ } catch (retryErr) {
1253
+ settleReject(retryErr);
1254
+ return;
1255
+ }
917
1256
  }
918
- });
919
- request.pipe(parserPipeline);
1257
+ })();
920
1258
  });
921
1259
  }
922
1260
 
@@ -925,35 +1263,46 @@ async function queryStream(rawConfig, view, options, onRow) {
925
1263
  const put = async (configInput, doc) => {
926
1264
  const config = CouchConfig.parse(configInput);
927
1265
  const logger = createLogger(config);
928
- const url = `${config.couch}/${doc._id}`;
1266
+ const url = createCouchDocUrl(doc._id, config.couch);
929
1267
  const body = doc;
930
- const mergedOpts = mergeNeedleOpts(config, {
931
- json: true,
932
- headers: { "Content-Type": "application/json" }
933
- });
934
1268
  logger.info(`Putting document with id: ${doc._id}`);
935
1269
  let resp;
936
1270
  try {
937
- resp = await needle("put", url, body, mergedOpts);
1271
+ resp = await fetchCouchJson({
1272
+ auth: config.auth,
1273
+ method: "PUT",
1274
+ operation: "put",
1275
+ request: config.request,
1276
+ url,
1277
+ body
1278
+ });
938
1279
  } catch (err) {
939
1280
  logger.error("Error during put operation:", err);
940
- RetryableError.handleNetworkError(err);
1281
+ RetryableError.handleNetworkError(err, "put");
941
1282
  }
942
1283
  if (!resp) {
943
1284
  logger.error("No response received from put request");
944
- throw new RetryableError("no response", 503);
1285
+ throw new RetryableError("Put failed", 503, { operation: "put" });
945
1286
  }
946
- const result = resp?.body || {};
1287
+ const result = { ...isRecord(resp.body) ? resp.body : {} };
947
1288
  result.statusCode = resp.statusCode;
948
1289
  if (resp.statusCode === 409) {
949
1290
  logger.warn(`Conflict detected for document: ${doc._id}`);
950
- result.ok = false;
951
- result.error = "conflict";
952
- return CouchPutResponse.parse(result);
1291
+ throw new ConflictError(doc._id, {
1292
+ couchError: typeof result.error === "string" ? result.error : void 0,
1293
+ operation: "put",
1294
+ statusCode: resp.statusCode
1295
+ });
953
1296
  }
954
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
955
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
956
- throw new RetryableError(result.reason || "retryable error", resp.statusCode);
1297
+ if (!isSuccessStatusCode("documentWrite", resp.statusCode) || !result.ok) {
1298
+ logger.error(`Unexpected status code: ${resp.statusCode}`);
1299
+ throw createResponseError({
1300
+ body: resp.body,
1301
+ defaultMessage: "Put failed",
1302
+ docId: doc._id,
1303
+ operation: "put",
1304
+ statusCode: resp.statusCode
1305
+ });
957
1306
  }
958
1307
  logger.info(`Successfully saved document: ${doc._id}`);
959
1308
  return CouchPutResponse.parse(result);
@@ -971,7 +1320,10 @@ const PatchProperties = z$1.looseObject({ _rev: z$1.string("_rev is required for
971
1320
  * @param _properties - Properties to merge into the document (must include _rev)
972
1321
  * @returns The result of the put operation
973
1322
  *
974
- * @throws Error if the _rev does not match or other errors occur
1323
+ * @throws {ConflictError} When the supplied `_rev` does not match the current document revision.
1324
+ * @throws {NotFoundError} When the document does not exist.
1325
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs while reading or saving.
1326
+ * @throws {OperationError} When a non-retryable operational failure occurs.
975
1327
  */
976
1328
  const patch = async (configInput, id, _properties) => {
977
1329
  const config = CouchConfig.parse(configInput);
@@ -979,12 +1331,15 @@ const patch = async (configInput, id, _properties) => {
979
1331
  const logger = createLogger(configInput);
980
1332
  logger.info(`Starting patch operation for document ${id}`);
981
1333
  logger.debug("Patch properties:", properties);
982
- const doc = await get(config, id);
983
- if (doc?._rev !== properties._rev) return {
984
- statusCode: 409,
985
- ok: false,
986
- error: "conflict"
987
- };
1334
+ const doc = await get({
1335
+ ...config,
1336
+ throwOnGetNotFound: true
1337
+ }, id);
1338
+ if (!doc) throw new OperationError("Patch failed", {
1339
+ docId: id,
1340
+ operation: "patch"
1341
+ });
1342
+ if (doc._rev !== properties._rev) throw new ConflictError(id, { operation: "patch" });
988
1343
  const updatedDoc = {
989
1344
  ...doc,
990
1345
  ...properties
@@ -1005,7 +1360,9 @@ const patch = async (configInput, id, _properties) => {
1005
1360
  * @param properties - Properties to merge into the document
1006
1361
  * @returns The result of the put operation or an error if max retries are exceeded
1007
1362
  *
1008
- * @throws Error if max retries are exceeded or other errors occur
1363
+ * @throws {NotFoundError} When the document does not exist.
1364
+ * @throws {RetryableError} When a retryable transport or HTTP failure occurs before retries are exhausted.
1365
+ * @throws {OperationError} When retries are exhausted or a non-retryable operational failure occurs.
1009
1366
  */
1010
1367
  const patchDangerously = async (configInput, id, properties) => {
1011
1368
  const config = CouchConfig.parse(configInput);
@@ -1015,65 +1372,66 @@ const patchDangerously = async (configInput, id, properties) => {
1015
1372
  let attempts = 0;
1016
1373
  logger.info(`Starting patch operation for document ${id}`);
1017
1374
  logger.debug("Patch properties:", properties);
1375
+ let lastError;
1018
1376
  while (attempts <= maxRetries) {
1019
1377
  logger.debug(`Attempt ${attempts + 1} of ${maxRetries + 1}`);
1020
1378
  try {
1021
- const doc = await get(config, id);
1022
- if (!doc) {
1023
- logger.warn(`Document ${id} not found`);
1024
- return {
1025
- ok: false,
1026
- statusCode: 404,
1027
- error: "not_found"
1028
- };
1029
- }
1379
+ const doc = await get({
1380
+ ...config,
1381
+ throwOnGetNotFound: true
1382
+ }, id);
1383
+ if (!doc) throw new OperationError("Patch failed", {
1384
+ docId: id,
1385
+ operation: "patchDangerously"
1386
+ });
1030
1387
  const updatedDoc = {
1031
1388
  ...doc,
1032
1389
  ...properties
1033
1390
  };
1034
1391
  logger.debug("Merged document:", updatedDoc);
1035
1392
  const result = await put(config, updatedDoc);
1036
- if (result.ok) {
1037
- logger.info(`Successfully patched document ${id}, rev: ${result.rev}`);
1038
- return result;
1039
- }
1040
- attempts++;
1041
- if (attempts > maxRetries) {
1042
- logger.error(`Failed to patch ${id} after ${maxRetries} attempts`);
1043
- throw new Error(`Failed to patch after ${maxRetries} attempts`);
1044
- }
1045
- logger.warn(`Conflict detected for ${id}, retrying (attempt ${attempts})`);
1046
- await setTimeout$1(delay);
1047
- delay *= config.backoffFactor || 2;
1048
- logger.debug(`Next retry delay: ${delay}ms`);
1393
+ logger.info(`Successfully patched document ${id}, rev: ${result.rev}`);
1394
+ return result;
1049
1395
  } catch (err) {
1050
- if (typeof err === "object" && err !== null && "message" in err && err.message === "not_found") {
1051
- logger.warn(`Document ${id} not found during patch operation`);
1052
- return {
1053
- ok: false,
1054
- statusCode: 404,
1055
- error: "not_found"
1056
- };
1057
- }
1396
+ if (!(err instanceof Error)) throw err;
1397
+ if (!(err instanceof ConflictError) && !(err instanceof RetryableError)) throw err;
1398
+ lastError = err;
1058
1399
  attempts++;
1059
1400
  if (attempts > maxRetries) {
1060
- const error = `Failed to patch after ${maxRetries} attempts: ${err}`;
1061
- logger.error(error);
1062
- return {
1063
- ok: false,
1064
- statusCode: 500,
1065
- error
1066
- };
1401
+ logger.error(`Failed to patch ${id} after ${maxRetries} attempts`, err);
1402
+ throw new OperationError("Patch failed", {
1403
+ cause: err,
1404
+ couchError: err instanceof HideABedError ? err.couchError : void 0,
1405
+ docId: id,
1406
+ operation: "patchDangerously",
1407
+ statusCode: err instanceof HideABedError ? err.statusCode : void 0
1408
+ });
1067
1409
  }
1068
1410
  logger.warn(`Error during patch attempt ${attempts}: ${err}`);
1069
1411
  await setTimeout$1(delay);
1412
+ delay *= config.backoffFactor || 2;
1070
1413
  logger.debug(`Retrying after ${delay}ms`);
1071
1414
  }
1072
1415
  }
1416
+ throw new OperationError("Patch failed", {
1417
+ cause: lastError,
1418
+ docId: id,
1419
+ operation: "patchDangerously"
1420
+ });
1073
1421
  };
1074
1422
 
1075
1423
  //#endregion
1076
1424
  //#region impl/query.mts
1425
+ const ValidSchema = z$1.custom((value) => {
1426
+ return value !== null && typeof value === "object" && "~standard" in value;
1427
+ }, { message: "schema must be a valid StandardSchemaV1 schema" });
1428
+ const QueryValidationSchema = z$1.object({
1429
+ docSchema: ValidSchema.optional(),
1430
+ keySchema: ValidSchema.optional(),
1431
+ onInvalidDoc: z$1.enum(["skip", "throw"]).optional(),
1432
+ valueSchema: ValidSchema.optional()
1433
+ }).optional();
1434
+ const QueryOptionsSchema = ViewOptions.extend({ validate: QueryValidationSchema }).strict();
1077
1435
  /**
1078
1436
  * Executes a CouchDB view query with optional schema validation and automatic handling
1079
1437
  * of HTTP method selection, query string construction, and retryable errors.
@@ -1093,69 +1451,85 @@ const patchDangerously = async (configInput, id, properties) => {
1093
1451
  * @returns The parsed view response with rows validated against the supplied schemas.
1094
1452
  *
1095
1453
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
1096
- * @throws {Error<Array<StandardSchemaV1.Issue>>} When the configuration or validation schemas fail to parse.
1097
- * @throws {Error} When CouchDB returns a non-retryable error payload.
1454
+ * @throws {ValidationError} When row, key, value, or included document validation fails.
1455
+ * @throws {OperationError} When CouchDB returns a non-retryable response or malformed row payload.
1098
1456
  */
1099
1457
  async function query(_config, view, options = {}) {
1100
1458
  const configParseResult = CouchConfig.safeParse(_config);
1459
+ const parsedOptions = QueryOptionsSchema.parse(options || {});
1101
1460
  const logger = createLogger(_config);
1102
1461
  logger.info(`Starting view query: ${view}`);
1103
- logger.debug("Query options:", ViewOptions.parse(options || {}));
1462
+ logger.debug("Query options:", parsedOptions);
1104
1463
  if (!configParseResult.success) {
1105
1464
  logger.error(`Invalid configuration provided: ${z$1.prettifyError(configParseResult.error)}`);
1106
1465
  throw configParseResult.error;
1107
1466
  }
1108
1467
  const config = configParseResult.data;
1109
- let qs = queryString(options);
1110
- let method = "get";
1468
+ let qs = queryString(parsedOptions);
1469
+ let method = "GET";
1111
1470
  let payload = null;
1112
- const mergedOpts = mergeNeedleOpts(config, {
1113
- json: true,
1114
- headers: { "Content-Type": "application/json" }
1115
- });
1116
- if (typeof options.keys !== "undefined") {
1471
+ if (typeof parsedOptions.keys !== "undefined") {
1117
1472
  const MAX_URL_LENGTH = 2e3;
1118
- const _options = structuredClone(options);
1119
- delete _options.keys;
1120
- qs = queryString(_options);
1121
- const keysAsString = `keys=${JSON.stringify(options.keys)}`;
1473
+ const { keys, validate, ...queryableOptions } = parsedOptions;
1474
+ qs = queryString(queryableOptions);
1475
+ const keysAsString = `keys=${JSON.stringify(keys)}`;
1122
1476
  if (keysAsString.length + qs.length + 1 <= MAX_URL_LENGTH) {
1123
- method = "get";
1477
+ method = "GET";
1124
1478
  if (qs.length > 0) qs += "&";
1125
1479
  else qs = "";
1126
1480
  qs += keysAsString;
1127
1481
  } else {
1128
- method = "post";
1129
- payload = { keys: options.keys };
1482
+ method = "POST";
1483
+ payload = { keys: parsedOptions.keys };
1130
1484
  }
1131
1485
  }
1132
1486
  logger.debug("Generated query string:", qs);
1133
- const url = `${config.couch}/${view}?${qs}`;
1487
+ const url = createCouchPathUrl(view, config.couch);
1488
+ if (qs) url.search = qs;
1134
1489
  let results;
1135
1490
  try {
1136
1491
  logger.debug(`Sending ${method} request to: ${url}`);
1137
- results = method === "get" ? await needle("get", url, mergedOpts) : await needle("post", url, payload, mergedOpts);
1492
+ results = await fetchCouchJson({
1493
+ auth: config.auth,
1494
+ method,
1495
+ operation: "query",
1496
+ request: config.request,
1497
+ url,
1498
+ body: method === "POST" ? payload : void 0
1499
+ });
1138
1500
  } catch (err) {
1139
1501
  logger.error("Network error during query:", err);
1140
- RetryableError.handleNetworkError(err);
1502
+ RetryableError.handleNetworkError(err, "query");
1141
1503
  }
1142
1504
  if (!results) {
1143
1505
  logger.error("No response received from query request");
1144
- throw new RetryableError("no response", 503);
1506
+ throw new RetryableError("Query failed", 503, { operation: "query" });
1145
1507
  }
1146
1508
  const body = results.body;
1147
- if (RetryableError.isRetryableStatusCode(results.statusCode)) {
1148
- logger.warn(`Retryable status code received: ${results.statusCode}`);
1149
- throw new RetryableError(body.error || "retryable error during query", results.statusCode);
1150
- }
1151
- if (body.error) {
1152
- logger.error(`Query error: ${JSON.stringify(body)}`);
1153
- throw new Error(`CouchDB query error: ${body.error} - ${body.reason || ""}`);
1509
+ if (!isSuccessStatusCode("viewQuery", results.statusCode) || body.error) {
1510
+ if (body.error) logger.error(`Query error: ${JSON.stringify(body)}`);
1511
+ else logger.error(`Unexpected status code: ${results.statusCode}`);
1512
+ throw createResponseError({
1513
+ body,
1514
+ defaultMessage: "Query failed",
1515
+ operation: "query",
1516
+ statusCode: results.statusCode
1517
+ });
1154
1518
  }
1155
- if (options.validate && body.rows) body.rows = await parseRows(body.rows, options.validate);
1519
+ const rows = options.validate && body.rows ? await parseRows(body.rows, {
1520
+ ...options.validate,
1521
+ defaultMessage: "Query failed",
1522
+ operation: "query"
1523
+ }) : body.rows ?? [];
1156
1524
  logger.info(`Successfully executed view query: ${view}`);
1157
- logger.debug("Query response:", body);
1158
- return body;
1525
+ logger.debug("Query response:", {
1526
+ ...body,
1527
+ rows
1528
+ });
1529
+ return {
1530
+ ...body,
1531
+ rows
1532
+ };
1159
1533
  }
1160
1534
 
1161
1535
  //#endregion
@@ -1167,35 +1541,35 @@ const setupEmitter = (config) => {
1167
1541
 
1168
1542
  //#endregion
1169
1543
  //#region impl/utils/transactionErrors.mts
1170
- var TransactionSetupError = class extends Error {
1544
+ var TransactionSetupError = class extends OperationError {
1171
1545
  details;
1172
1546
  constructor(message, details = {}) {
1173
- super(message);
1547
+ super(message, { category: "transaction" });
1174
1548
  this.name = "TransactionSetupError";
1175
1549
  this.details = details;
1176
1550
  }
1177
1551
  };
1178
- var TransactionVersionConflictError = class extends Error {
1552
+ var TransactionVersionConflictError = class extends OperationError {
1179
1553
  conflictingIds;
1180
1554
  constructor(conflictingIds) {
1181
- super(`Revision mismatch for documents: ${conflictingIds.join(", ")}`);
1555
+ super(`Revision mismatch for documents: ${conflictingIds.join(", ")}`, { category: "transaction" });
1182
1556
  this.name = "TransactionVersionConflictError";
1183
1557
  this.conflictingIds = conflictingIds;
1184
1558
  }
1185
1559
  };
1186
- var TransactionBulkOperationError = class extends Error {
1560
+ var TransactionBulkOperationError = class extends OperationError {
1187
1561
  failedDocs;
1188
1562
  constructor(failedDocs) {
1189
- super(`Failed to save documents: ${failedDocs.map((d) => d.id).join(", ")}`);
1563
+ super(`Failed to save documents: ${failedDocs.map((d) => d.id).join(", ")}`, { category: "transaction" });
1190
1564
  this.name = "TransactionBulkOperationError";
1191
1565
  this.failedDocs = failedDocs;
1192
1566
  }
1193
1567
  };
1194
- var TransactionRollbackError = class extends Error {
1568
+ var TransactionRollbackError = class extends OperationError {
1195
1569
  originalError;
1196
1570
  rollbackResults;
1197
1571
  constructor(message, originalError, rollbackResults) {
1198
- super(message);
1572
+ super(message, { category: "transaction" });
1199
1573
  this.name = "TransactionRollbackError";
1200
1574
  this.originalError = originalError;
1201
1575
  this.rollbackResults = rollbackResults;
@@ -1215,39 +1589,48 @@ var TransactionRollbackError = class extends Error {
1215
1589
  * @returns {Promise<BulkSaveResponse>} - The response from CouchDB after the bulk save operation.
1216
1590
  *
1217
1591
  * @throws {RetryableError} When a retryable HTTP status code is encountered or no response is received.
1218
- * @throws {Error} When CouchDB returns a non-retryable error payload.
1592
+ * @throws {OperationError} When bulk save input is invalid or CouchDB returns a non-retryable request-level failure.
1219
1593
  */
1220
1594
  const bulkSave = async (config, docs) => {
1221
- const logger = createLogger(config);
1595
+ const parsedConfig = CouchConfig.parse(config);
1596
+ const logger = createLogger(parsedConfig);
1222
1597
  if (docs == null || !docs.length) {
1223
1598
  logger.error("bulkSave called with no docs");
1224
- throw new Error("no docs provided");
1599
+ throw new OperationError("Bulk save requires at least one document", { operation: "request" });
1225
1600
  }
1226
1601
  logger.info(`Starting bulk save of ${docs.length} documents`);
1227
- const url = `${config.couch}/_bulk_docs`;
1602
+ const url = createCouchPathUrl("_bulk_docs", parsedConfig.couch);
1228
1603
  const body = { docs };
1229
- const mergedOpts = mergeNeedleOpts(config, {
1230
- json: true,
1231
- headers: { "Content-Type": "application/json" }
1232
- });
1233
1604
  let resp;
1234
1605
  try {
1235
- resp = await needle("post", url, body, mergedOpts);
1606
+ resp = await fetchCouchJson({
1607
+ auth: parsedConfig.auth,
1608
+ method: "POST",
1609
+ operation: "request",
1610
+ request: parsedConfig.request,
1611
+ url,
1612
+ body
1613
+ });
1236
1614
  } catch (err) {
1237
1615
  logger.error("Network error during bulk save:", err);
1238
- RetryableError.handleNetworkError(err);
1616
+ RetryableError.handleNetworkError(err, "request");
1239
1617
  }
1240
1618
  if (!resp) {
1241
1619
  logger.error("No response received from bulk save request");
1242
- throw new RetryableError("no response", 503);
1620
+ throw new RetryableError("Bulk save failed", 503, { operation: "request" });
1243
1621
  }
1244
1622
  if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1245
1623
  logger.warn(`Retryable status code received: ${resp.statusCode}`);
1246
- throw new RetryableError("retryable error during bulk save", resp.statusCode);
1624
+ throw new RetryableError("Bulk save failed", resp.statusCode, { operation: "request" });
1247
1625
  }
1248
- if (resp.statusCode !== 201) {
1626
+ if (!isSuccessStatusCode("bulkSave", resp.statusCode)) {
1249
1627
  logger.error(`Unexpected status code: ${resp.statusCode}`);
1250
- throw new Error("could not save");
1628
+ throw createResponseError({
1629
+ body: resp.body,
1630
+ defaultMessage: "Bulk save failed",
1631
+ operation: "request",
1632
+ statusCode: resp.statusCode
1633
+ });
1251
1634
  }
1252
1635
  const results = resp?.body || [];
1253
1636
  return BulkSaveResponse.parse(results);
@@ -1310,16 +1693,21 @@ const bulkSaveTransaction = async (config, transactionId, docs) => {
1310
1693
  changes: docs,
1311
1694
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1312
1695
  };
1313
- let transactionResponse = await _put(transactionDoc);
1696
+ let transactionResponse;
1697
+ try {
1698
+ transactionResponse = await _put(transactionDoc);
1699
+ } catch (error) {
1700
+ if (error instanceof ConflictError) throw new TransactionSetupError("Failed to create transaction document", {
1701
+ error: error.couchError,
1702
+ response: error
1703
+ });
1704
+ throw error;
1705
+ }
1314
1706
  logger.debug("Transaction document created:", transactionDoc, transactionResponse);
1315
1707
  await emitter.emit("transaction-created", {
1316
1708
  transactionResponse,
1317
1709
  txnDoc: transactionDoc
1318
1710
  });
1319
- if (transactionResponse.error) throw new TransactionSetupError("Failed to create transaction document", {
1320
- error: transactionResponse.error,
1321
- response: transactionResponse
1322
- });
1323
1711
  const existingDocs = await bulkGetDictionary(config, docs.map((d) => d._id));
1324
1712
  logger.debug("Fetched current revisions of documents:", existingDocs);
1325
1713
  await emitter.emit("transaction-revs-fetched", existingDocs);
@@ -1412,44 +1800,44 @@ const bulkSaveTransaction = async (config, transactionId, docs) => {
1412
1800
  const remove = async (configInput, id, rev) => {
1413
1801
  const config = CouchConfig.parse(configInput);
1414
1802
  const logger = createLogger(config);
1415
- const url = `${config.couch}/${id}?rev=${rev}`;
1416
- const mergedOpts = mergeNeedleOpts(config, {
1417
- json: true,
1418
- headers: { "Content-Type": "application/json" }
1419
- });
1803
+ const url = createCouchDocUrl(id, config.couch);
1804
+ url.searchParams.set("rev", rev);
1420
1805
  logger.info(`Deleting document with id: ${id}`);
1421
1806
  let resp;
1422
1807
  try {
1423
- resp = await needle("delete", url, null, mergedOpts);
1808
+ resp = await fetchCouchJson({
1809
+ auth: config.auth,
1810
+ method: "DELETE",
1811
+ operation: "remove",
1812
+ request: config.request,
1813
+ url
1814
+ });
1424
1815
  } catch (err) {
1425
1816
  logger.error("Error during delete operation:", err);
1426
- RetryableError.handleNetworkError(err);
1817
+ RetryableError.handleNetworkError(err, "remove");
1427
1818
  }
1428
1819
  if (!resp) {
1429
1820
  logger.error("No response received from delete request");
1430
- throw new RetryableError("no response", 503);
1431
- }
1432
- let result;
1433
- if (typeof resp.body === "string") try {
1434
- result = JSON.parse(resp.body);
1435
- } catch {
1436
- result = {};
1821
+ throw new RetryableError("Remove failed", 503, { operation: "remove" });
1437
1822
  }
1438
- else result = resp.body || {};
1823
+ const result = { ...isRecord(resp.body) ? resp.body : {} };
1439
1824
  result.statusCode = resp.statusCode;
1440
1825
  if (resp.statusCode === 404) {
1441
1826
  logger.warn(`Document not found for deletion: ${id}`);
1442
- result.ok = false;
1443
- result.error = "not_found";
1444
- return CouchPutResponse.parse(result);
1445
- }
1446
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1447
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
1448
- throw new RetryableError(result.reason || "retryable error", resp.statusCode);
1827
+ throw new NotFoundError(id, {
1828
+ operation: "remove",
1829
+ statusCode: resp.statusCode
1830
+ });
1449
1831
  }
1450
- if (resp.statusCode !== 200) {
1832
+ if (!isSuccessStatusCode("documentDelete", resp.statusCode) || !result.ok) {
1451
1833
  logger.error(`Unexpected status code: ${resp.statusCode}`);
1452
- throw new Error(result.reason || "failed");
1834
+ throw createResponseError({
1835
+ body: resp.body,
1836
+ defaultMessage: "Remove failed",
1837
+ docId: id,
1838
+ operation: "remove",
1839
+ statusCode: resp.statusCode
1840
+ });
1453
1841
  }
1454
1842
  logger.info(`Successfully deleted document: ${id}`);
1455
1843
  return CouchPutResponse.parse(result);
@@ -1479,7 +1867,8 @@ const remove = async (configInput, id, rev) => {
1479
1867
  * console.log(results);
1480
1868
  * ```
1481
1869
  *
1482
- * @throws Will throw an error if the provided configuration is invalid or if the bulk delete operation fails.
1870
+ * @throws {RetryableError} When the bulk request fails with a retryable transport or HTTP error.
1871
+ * @throws {OperationError} When CouchDB returns a non-retryable request-level failure.
1483
1872
  */
1484
1873
  const bulkRemove = async (configInput, ids) => {
1485
1874
  const config = CouchConfig.parse(configInput);
@@ -1521,7 +1910,8 @@ const bulkRemove = async (configInput, ids) => {
1521
1910
  * console.log(results);
1522
1911
  * ```
1523
1912
  *
1524
- * @throws Will throw an error if the provided configuration is invalid or if any delete operation fails.
1913
+ * @throws {RetryableError} When a request-level transport or retryable HTTP failure occurs.
1914
+ * @throws {OperationError} When a request fails in a non-retryable way.
1525
1915
  */
1526
1916
  const bulkRemoveMap = async (configInput, ids) => {
1527
1917
  const config = CouchConfig.parse(configInput);
@@ -1550,7 +1940,7 @@ const bulkRemoveMap = async (configInput, ids) => {
1550
1940
  * @param configInput - The CouchDB configuration input.
1551
1941
  * @returns A promise that resolves to the CouchDB database information.
1552
1942
  * @throws {RetryableError} `RetryableError` If a retryable error occurs during the request.
1553
- * @throws {Error} `Error` For other non-retryable errors.
1943
+ * @throws {OperationError} `OperationError` For other non-retryable response failures.
1554
1944
  *
1555
1945
  * @example
1556
1946
  * ```ts
@@ -1570,27 +1960,34 @@ const bulkRemoveMap = async (configInput, ids) => {
1570
1960
  const getDBInfo = async (configInput) => {
1571
1961
  const config = CouchConfig.parse(configInput);
1572
1962
  const logger = createLogger(config);
1573
- const url = `${config.couch}`;
1963
+ const url = createCouchDbUrl(config.couch);
1574
1964
  let resp;
1575
1965
  try {
1576
- resp = await needle("get", url, mergeNeedleOpts(config, {
1577
- json: true,
1578
- headers: { "Content-Type": "application/json" }
1579
- }));
1966
+ resp = await fetchCouchJson({
1967
+ auth: config.auth,
1968
+ method: "GET",
1969
+ operation: "getDBInfo",
1970
+ request: config.request,
1971
+ url
1972
+ });
1973
+ if (!isSuccessStatusCode("database", resp.statusCode)) {
1974
+ logger.error(`Non-success status code received: ${resp.statusCode}`);
1975
+ throw createResponseError({
1976
+ body: resp.body,
1977
+ defaultMessage: "Failed to fetch database info",
1978
+ operation: "getDBInfo",
1979
+ statusCode: resp.statusCode
1980
+ });
1981
+ }
1580
1982
  } catch (err) {
1581
1983
  logger.error("Error during get operation:", err);
1582
- RetryableError.handleNetworkError(err);
1984
+ RetryableError.handleNetworkError(err, "getDBInfo");
1583
1985
  }
1584
1986
  if (!resp) {
1585
1987
  logger.error("No response received from get request");
1586
- throw new RetryableError("no response", 503);
1988
+ throw new RetryableError("Failed to fetch database info", 503, { operation: "getDBInfo" });
1587
1989
  }
1588
- const result = resp.body;
1589
- if (RetryableError.isRetryableStatusCode(resp.statusCode)) {
1590
- logger.warn(`Retryable status code received: ${resp.statusCode}`);
1591
- throw new RetryableError(result.reason ?? "retryable error", resp.statusCode);
1592
- }
1593
- return CouchDBInfo.parse(result);
1990
+ return CouchDBInfo.parse(resp.body);
1594
1991
  };
1595
1992
 
1596
1993
  //#endregion
@@ -1686,7 +2083,7 @@ async function removeLock(configInput, docId, lockOptions) {
1686
2083
 
1687
2084
  //#endregion
1688
2085
  //#region schema/sugar/watch.mts
1689
- const WatchOptions = z$1.object({
2086
+ const WatchOptions = z$1.strictObject({
1690
2087
  include_docs: z$1.boolean().default(false),
1691
2088
  maxRetries: z$1.number().describe("maximum number of retries before giving up"),
1692
2089
  initialDelay: z$1.number().describe("initial delay between retries in milliseconds"),
@@ -1712,112 +2109,155 @@ function watchDocs(configInput, docIds, onChange, optionsInput = {}) {
1712
2109
  const options = WatchOptions.parse(optionsInput);
1713
2110
  const logger = createLogger(config);
1714
2111
  const emitter = new EventEmitter();
2112
+ const request = config.request;
1715
2113
  let lastSeq = null;
1716
2114
  let stopping = false;
2115
+ let stopEndEmitted = false;
1717
2116
  let retryCount = 0;
1718
- let currentRequest = null;
2117
+ const lifecycleAbortController = new AbortController();
2118
+ let currentAbortController = null;
1719
2119
  const maxRetries = options.maxRetries || 10;
1720
2120
  const initialDelay = options.initialDelay || 1e3;
1721
2121
  const maxDelay = options.maxDelay || 3e4;
1722
2122
  const _docIds = Array.isArray(docIds) ? docIds : [docIds];
1723
- if (_docIds.length === 0) throw new Error("docIds must be a non-empty array");
1724
- if (_docIds.length > 100) throw new Error("docIds must be an array of 100 or fewer elements");
2123
+ if (_docIds.length === 0) throw new OperationError("docIds must be a non-empty array", { operation: "watchDocs" });
2124
+ if (_docIds.length > 100) throw new OperationError("docIds must be an array of 100 or fewer elements", { operation: "watchDocs" });
2125
+ const emitStopEnd = () => {
2126
+ if (stopEndEmitted) return;
2127
+ stopEndEmitted = true;
2128
+ emitter.emit("end", { lastSeq });
2129
+ };
2130
+ const stopWatching = () => {
2131
+ if (stopping) return;
2132
+ stopping = true;
2133
+ lifecycleAbortController.abort();
2134
+ currentAbortController?.abort();
2135
+ request?.signal?.removeEventListener("abort", handleExternalAbort);
2136
+ emitStopEnd();
2137
+ emitter.removeAllListeners();
2138
+ };
2139
+ const handleExternalAbort = () => {
2140
+ logger.info(`Request signal aborted, stopping watcher for [${_docIds}]`);
2141
+ stopWatching();
2142
+ };
2143
+ request?.signal?.addEventListener("abort", handleExternalAbort, { once: true });
1725
2144
  const connect = async () => {
1726
2145
  if (stopping) return;
1727
2146
  const feed = "continuous";
1728
2147
  const includeDocs = options.include_docs ?? false;
1729
- const ids = _docIds.join("\",\"");
1730
- const url = `${config.couch}/_changes?feed=${feed}&since=${lastSeq}&include_docs=${includeDocs}&filter=_doc_ids&doc_ids=["${ids}"]`;
1731
- const mergedOpts = mergeNeedleOpts(config, {
1732
- json: false,
1733
- headers: { "Content-Type": "application/json" },
1734
- parse_response: false
1735
- });
2148
+ const url = createCouchPathUrl("_changes", config.couch);
2149
+ url.searchParams.set("feed", feed);
2150
+ url.searchParams.set("since", String(lastSeq));
2151
+ url.searchParams.set("include_docs", String(includeDocs));
2152
+ url.searchParams.set("filter", "_doc_ids");
2153
+ url.searchParams.set("doc_ids", JSON.stringify(_docIds));
2154
+ const abortController = new AbortController();
2155
+ currentAbortController = abortController;
1736
2156
  let buffer = "";
1737
- currentRequest = needle.get(url, mergedOpts);
1738
- currentRequest.on("data", (chunk) => {
1739
- buffer += chunk.toString();
1740
- const lines = buffer.split("\n");
1741
- buffer = lines.pop() || "";
1742
- for (const line of lines) if (line.trim()) try {
2157
+ const processLine = (line) => {
2158
+ if (!line.trim()) return;
2159
+ try {
1743
2160
  const change = JSON.parse(line);
1744
- if (!change.id) return null;
2161
+ if (!change.id) return;
1745
2162
  logger.debug(`Change detected, watching [${_docIds}]`, change);
1746
2163
  lastSeq = change.seq || change.last_seq;
1747
2164
  emitter.emit("change", change);
1748
2165
  } catch (err) {
1749
2166
  logger.error("Error parsing change:", err, "Line:", line);
1750
2167
  }
1751
- });
1752
- currentRequest.on("response", (response) => {
2168
+ };
2169
+ try {
2170
+ const response = await fetchCouchStream({
2171
+ auth: config.auth,
2172
+ method: "GET",
2173
+ operation: "watchDocs",
2174
+ url,
2175
+ headers: { "Content-Type": "application/json" },
2176
+ request,
2177
+ signal: AbortSignal.any([abortController.signal, lifecycleAbortController.signal])
2178
+ });
1753
2179
  logger.debug(`Received response with status code, watching [${_docIds}]: ${response.statusCode}`);
1754
2180
  if (RetryableError.isRetryableStatusCode(response.statusCode)) {
1755
2181
  logger.warn(`Retryable status code received: ${response.statusCode}`);
1756
- currentRequest?.destroy();
1757
- handleReconnect();
1758
- } else retryCount = 0;
1759
- });
1760
- currentRequest.on("error", async (err) => {
1761
- if (stopping) {
2182
+ abortController.abort();
2183
+ await handleReconnect();
2184
+ return;
2185
+ }
2186
+ if (!isSuccessStatusCode("changesFeed", response.statusCode)) {
2187
+ emitter.emit("error", createResponseError({
2188
+ defaultMessage: "Watch request failed",
2189
+ operation: "watchDocs",
2190
+ statusCode: response.statusCode
2191
+ }));
2192
+ return;
2193
+ }
2194
+ retryCount = 0;
2195
+ if (!response.body) throw new RetryableError("Watch request failed", 503, { operation: "watchDocs" });
2196
+ const reader = response.body.getReader();
2197
+ const decoder = new TextDecoder();
2198
+ while (!stopping) {
2199
+ const { done, value } = await reader.read();
2200
+ if (done) break;
2201
+ buffer += decoder.decode(value, { stream: true });
2202
+ const lines = buffer.split("\n");
2203
+ buffer = lines.pop() || "";
2204
+ lines.forEach(processLine);
2205
+ }
2206
+ if (stopping || abortController.signal.aborted) return;
2207
+ buffer += decoder.decode();
2208
+ if (buffer.trim()) processLine(buffer);
2209
+ logger.info("Stream completed. Last seen seq: ", lastSeq);
2210
+ emitter.emit("end", { lastSeq });
2211
+ if (!stopping) await handleReconnect();
2212
+ } catch (err) {
2213
+ if (stopping || abortController.signal.aborted) {
1762
2214
  logger.info("stopping in progress, ignore stream error");
1763
2215
  return;
1764
2216
  }
1765
- logger.error(`Network error during stream, watching [${_docIds}]:`, err.toString());
2217
+ logger.error(`Network error during stream, watching [${_docIds}]:`, String(err));
1766
2218
  try {
1767
- RetryableError.handleNetworkError(err);
2219
+ RetryableError.handleNetworkError(err, "watchDocs");
1768
2220
  } catch (filteredError) {
1769
2221
  if (filteredError instanceof RetryableError) {
1770
2222
  logger.info(`Retryable error, watching [${_docIds}]:`, filteredError.toString());
1771
- handleReconnect();
2223
+ await handleReconnect();
1772
2224
  } else {
1773
- logger.error(`Non-retryable error, watching [${_docIds}]`, filteredError?.toString());
2225
+ logger.error(`Non-retryable error, watching [${_docIds}]`, String(filteredError));
1774
2226
  emitter.emit("error", filteredError);
1775
2227
  }
1776
2228
  }
1777
- });
1778
- currentRequest.on("end", () => {
1779
- if (buffer.trim()) try {
1780
- const change = JSON.parse(buffer);
1781
- logger.debug("Final change detected:", change);
1782
- emitter.emit("change", change);
1783
- } catch (err) {
1784
- logger.error("Error parsing final change:", err);
1785
- }
1786
- logger.info("Stream completed. Last seen seq: ", lastSeq);
1787
- emitter.emit("end", { lastSeq });
1788
- if (!stopping) handleReconnect();
1789
- });
2229
+ }
1790
2230
  };
1791
2231
  const handleReconnect = async () => {
1792
2232
  if (stopping || retryCount >= maxRetries) {
1793
2233
  if (retryCount >= maxRetries) {
1794
2234
  logger.error(`Max retries (${maxRetries}) reached, giving up`);
1795
- emitter.emit("error", /* @__PURE__ */ new Error("Max retries reached"));
2235
+ emitter.emit("error", new OperationError("Watch retries exhausted", { operation: "watchDocs" }));
1796
2236
  }
1797
2237
  return;
1798
2238
  }
1799
2239
  const delay = Math.min(initialDelay * Math.pow(2, retryCount), maxDelay);
1800
2240
  retryCount++;
1801
2241
  logger.info(`Attempting to reconnect in ${delay}ms (attempt ${retryCount} of ${maxRetries})`);
1802
- await setTimeout$1(delay);
1803
2242
  try {
1804
- connect();
2243
+ await setTimeout$1(delay, void 0, { signal: lifecycleAbortController.signal });
2244
+ } catch {
2245
+ return;
2246
+ }
2247
+ try {
2248
+ await connect();
1805
2249
  } catch (err) {
1806
2250
  logger.error("Error during reconnection:", err);
1807
- handleReconnect();
2251
+ await handleReconnect();
1808
2252
  }
1809
2253
  };
1810
- connect();
1811
2254
  emitter.on("change", onChange);
2255
+ if (request?.signal?.aborted) stopWatching();
2256
+ else connect();
1812
2257
  return {
1813
2258
  on: (event, listener) => emitter.on(event, listener),
1814
2259
  removeListener: (event, listener) => emitter.removeListener(event, listener),
1815
- stop: () => {
1816
- stopping = true;
1817
- if (currentRequest) currentRequest.destroy();
1818
- emitter.emit("end", { lastSeq });
1819
- emitter.removeAllListeners();
1820
- }
2260
+ stop: stopWatching
1821
2261
  };
1822
2262
  }
1823
2263
 
@@ -1895,4 +2335,4 @@ function doBind(config) {
1895
2335
  }
1896
2336
 
1897
2337
  //#endregion
1898
- export { QueryBuilder, bindConfig, bulkGet, bulkGetDictionary, bulkRemove, bulkRemoveMap, bulkSave, bulkSaveTransaction, createLock, createQuery, get, getAtRev, getDBInfo, patch, patchDangerously, put, query, queryStream, remove, removeLock, watchDocs, withRetry };
2338
+ export { ConflictError, HideABedError, NotFoundError, OperationError, QueryBuilder, RetryableError, ValidationError, bindConfig, bulkGet, bulkGetDictionary, bulkRemove, bulkRemoveMap, bulkSave, bulkSaveTransaction, createLock, createQuery, get, getAtRev, getDBInfo, patch, patchDangerously, put, query, queryStream, remove, removeLock, watchDocs, withRetry };