@ekodb/ekodb-client 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -31,6 +31,12 @@ export interface RateLimitInfo {
31
31
  /**
32
32
  * Client configuration options
33
33
  */
34
+ /**
35
+ * Default per-request timeout (ms) for REST calls, matching the Rust and Go
36
+ * clients' 30s default. Applied to every non-streaming request; long-lived
37
+ * streaming/SSE calls are intentionally not bounded by it.
38
+ */
39
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
34
40
  export interface ClientConfig {
35
41
  /** Base URL of the ekoDB server */
36
42
  baseURL: string;
@@ -42,6 +48,13 @@ export interface ClientConfig {
42
48
  maxRetries?: number;
43
49
  /** Serialization format (default: MessagePack for best performance, use Json for debugging) */
44
50
  format?: SerializationFormat;
51
+ /**
52
+ * Per-request timeout in milliseconds for REST calls (default: 30000).
53
+ * A slow or unreachable server aborts with a timeout error instead of hanging
54
+ * the caller. Streaming/SSE calls are not bounded by it. Matches the Rust and
55
+ * Go clients.
56
+ */
57
+ timeout?: number;
45
58
  }
46
59
  /**
47
60
  * Rate limit error
@@ -342,6 +355,7 @@ export declare class EkoDBClient {
342
355
  private shouldRetry;
343
356
  private maxRetries;
344
357
  private format;
358
+ private requestTimeoutMs;
345
359
  private rateLimitInfo;
346
360
  constructor(config: string | ClientConfig, apiKey?: string);
347
361
  /**
@@ -508,16 +522,30 @@ export declare class EkoDBClient {
508
522
  batchInsert(collection: string, records: Record[], options?: BatchInsertOptions): Promise<BatchOperationResult>;
509
523
  /**
510
524
  * Batch update multiple documents
525
+ * @param collection - Collection name
526
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
527
+ * @param options - Optional parameters (bypassRipple, transactionId). When
528
+ * `transactionId` is set, the batch is staged into that MVCC transaction
529
+ * (sent as the `transaction_id` query param) instead of committed
530
+ * immediately — mirrors the single-record update.
511
531
  */
512
532
  batchUpdate(collection: string, updates: Array<{
513
533
  id: string;
514
534
  data: Record;
515
535
  bypassRipple?: boolean;
516
- }>): Promise<BatchOperationResult>;
536
+ }>, options?: BatchUpdateOptions): Promise<BatchOperationResult>;
517
537
  /**
518
538
  * Batch delete multiple documents
519
- */
520
- batchDelete(collection: string, ids: string[], bypassRipple?: boolean): Promise<BatchOperationResult>;
539
+ * @param collection - Collection name
540
+ * @param ids - Document IDs to delete
541
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
542
+ * positional form, kept for back-compat)
543
+ * @param options - Optional parameters (bypassRipple, transactionId). When
544
+ * `transactionId` is set, the batch is staged into that MVCC transaction
545
+ * (sent as the `transaction_id` query param) instead of committed
546
+ * immediately — mirrors the single-record delete.
547
+ */
548
+ batchDelete(collection: string, ids: string[], bypassRipple?: boolean, options?: BatchDeleteOptions): Promise<BatchOperationResult>;
521
549
  /**
522
550
  * Set a key-value pair with optional TTL
523
551
  * @param key - The key to set
package/dist/client.js CHANGED
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.WebSocketClient = exports.SchemaCache = exports.EventStream = exports.EkoDBClient = exports.MergeStrategy = exports.RateLimitError = exports.SerializationFormat = void 0;
39
+ exports.WebSocketClient = exports.SchemaCache = exports.EventStream = exports.EkoDBClient = exports.MergeStrategy = exports.RateLimitError = exports.DEFAULT_REQUEST_TIMEOUT_MS = exports.SerializationFormat = void 0;
40
40
  exports.extractRecordId = extractRecordId;
41
41
  const msgpack_1 = require("@msgpack/msgpack");
42
42
  const query_builder_1 = require("./query-builder");
@@ -51,6 +51,15 @@ var SerializationFormat;
51
51
  /** MessagePack format (binary, 2-3x faster) */
52
52
  SerializationFormat["MessagePack"] = "MessagePack";
53
53
  })(SerializationFormat || (exports.SerializationFormat = SerializationFormat = {}));
54
+ /**
55
+ * Client configuration options
56
+ */
57
+ /**
58
+ * Default per-request timeout (ms) for REST calls, matching the Rust and Go
59
+ * clients' 30s default. Applied to every non-streaming request; long-lived
60
+ * streaming/SSE calls are intentionally not bounded by it.
61
+ */
62
+ exports.DEFAULT_REQUEST_TIMEOUT_MS = 30000;
54
63
  /**
55
64
  * Rate limit error
56
65
  */
@@ -96,6 +105,7 @@ class EkoDBClient {
96
105
  this.shouldRetry = true;
97
106
  this.maxRetries = 3;
98
107
  this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
108
+ this.requestTimeoutMs = exports.DEFAULT_REQUEST_TIMEOUT_MS;
99
109
  }
100
110
  else {
101
111
  this.baseURL = stripTrailingSlashes(config.baseURL);
@@ -103,6 +113,7 @@ class EkoDBClient {
103
113
  this.shouldRetry = config.shouldRetry ?? true;
104
114
  this.maxRetries = config.maxRetries ?? 3;
105
115
  this.format = config.format ?? SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
116
+ this.requestTimeoutMs = config.timeout ?? exports.DEFAULT_REQUEST_TIMEOUT_MS;
106
117
  }
107
118
  }
108
119
  /**
@@ -134,6 +145,7 @@ class EkoDBClient {
134
145
  method: "POST",
135
146
  headers: { "Content-Type": "application/json" },
136
147
  body: JSON.stringify({ api_key: this.apiKey }),
148
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
137
149
  });
138
150
  if (!response.ok) {
139
151
  let errorMsg = `Auth failed with status: ${response.status}`;
@@ -317,6 +329,10 @@ class EkoDBClient {
317
329
  "Content-Type": contentType,
318
330
  Accept: contentType,
319
331
  },
332
+ // Bound every REST request so a slow/unreachable server aborts instead of
333
+ // hanging the caller. A fresh signal per attempt is correct here because
334
+ // makeRequest recurses on retry, rebuilding `options` each time.
335
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
320
336
  };
321
337
  if (data) {
322
338
  options.body = isMessagePack
@@ -371,6 +387,16 @@ class EkoDBClient {
371
387
  throw new Error(`Request failed with status ${response.status}: ${text}`);
372
388
  }
373
389
  catch (error) {
390
+ // A fired request-timeout (AbortSignal.timeout) surfaces as a DOMException
391
+ // named "TimeoutError" (or "AbortError" in some runtimes). Convert it to a
392
+ // clear error and do NOT retry — retrying would multiply the wait beyond
393
+ // the configured bound.
394
+ const errName = error && typeof error === "object" && "name" in error
395
+ ? error.name
396
+ : undefined;
397
+ if (errName === "TimeoutError" || errName === "AbortError") {
398
+ throw new Error(`Request timed out after ${this.requestTimeoutMs}ms: ${method} ${path}`);
399
+ }
374
400
  // Handle network errors with retry
375
401
  if (error instanceof TypeError &&
376
402
  this.shouldRetry &&
@@ -599,24 +625,61 @@ class EkoDBClient {
599
625
  }
600
626
  /**
601
627
  * Batch update multiple documents
628
+ * @param collection - Collection name
629
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
630
+ * @param options - Optional parameters (bypassRipple, transactionId). When
631
+ * `transactionId` is set, the batch is staged into that MVCC transaction
632
+ * (sent as the `transaction_id` query param) instead of committed
633
+ * immediately — mirrors the single-record update.
602
634
  */
603
- async batchUpdate(collection, updates) {
635
+ async batchUpdate(collection, updates, options) {
636
+ const params = new URLSearchParams();
637
+ if (options?.bypassRipple !== undefined) {
638
+ params.append("bypass_ripple", String(options.bypassRipple));
639
+ }
640
+ if (options?.transactionId) {
641
+ params.append("transaction_id", options.transactionId);
642
+ }
604
643
  const formattedUpdates = updates.map((u) => ({
605
644
  id: u.id,
606
645
  data: u.data,
607
646
  bypass_ripple: u.bypassRipple,
608
647
  }));
609
- return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
648
+ const url = params.toString()
649
+ ? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
650
+ : `/api/batch/update/${encodeURIComponent(collection)}`;
651
+ return this.makeRequest("PUT", url, {
652
+ updates: formattedUpdates,
653
+ });
610
654
  }
611
655
  /**
612
656
  * Batch delete multiple documents
613
- */
614
- async batchDelete(collection, ids, bypassRipple) {
657
+ * @param collection - Collection name
658
+ * @param ids - Document IDs to delete
659
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
660
+ * positional form, kept for back-compat)
661
+ * @param options - Optional parameters (bypassRipple, transactionId). When
662
+ * `transactionId` is set, the batch is staged into that MVCC transaction
663
+ * (sent as the `transaction_id` query param) instead of committed
664
+ * immediately — mirrors the single-record delete.
665
+ */
666
+ async batchDelete(collection, ids, bypassRipple, options) {
667
+ // bypass_ripple is sent per-item in the body (existing behavior). The
668
+ // effective value prefers the explicit positional arg, falling back to the
669
+ // options object so callers can use either form.
670
+ const effectiveBypassRipple = bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
671
+ const params = new URLSearchParams();
672
+ if (options?.transactionId) {
673
+ params.append("transaction_id", options.transactionId);
674
+ }
615
675
  const deletes = ids.map((id) => ({
616
676
  id: id,
617
- bypass_ripple: bypassRipple,
677
+ bypass_ripple: effectiveBypassRipple,
618
678
  }));
619
- return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
679
+ const url = params.toString()
680
+ ? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
681
+ : `/api/batch/delete/${encodeURIComponent(collection)}`;
682
+ return this.makeRequest("DELETE", url, { deletes });
620
683
  }
621
684
  /**
622
685
  * Set a key-value pair with optional TTL
@@ -1533,7 +1596,11 @@ class EkoDBClient {
1533
1596
  * List all functions, optionally filtered by tags
1534
1597
  */
1535
1598
  async listFunctions(tags) {
1536
- const params = tags ? `?tags=${tags.join(",")}` : "";
1599
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
1600
+ // containing query-reserved characters can't smuggle extra params.
1601
+ const params = tags
1602
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
1603
+ : "";
1537
1604
  return this.makeRequest("GET", `/api/functions${params}`);
1538
1605
  }
1539
1606
  /**
@@ -1580,7 +1647,11 @@ class EkoDBClient {
1580
1647
  * @returns Array of user functions
1581
1648
  */
1582
1649
  async listUserFunctions(tags) {
1583
- const params = tags ? `?tags=${tags.join(",")}` : "";
1650
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
1651
+ // containing query-reserved characters can't smuggle extra params.
1652
+ const params = tags
1653
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
1654
+ : "";
1584
1655
  return this.makeRequest("GET", `/api/functions${params}`, undefined, 0, true);
1585
1656
  }
1586
1657
  /**
@@ -227,6 +227,56 @@ function mockErrorResponse(status, message) {
227
227
  const result = await client.batchDelete("users", ["id_1", "id_2"]);
228
228
  (0, vitest_1.expect)(result.successful).toHaveLength(2);
229
229
  });
230
+ (0, vitest_1.it)("batchInsert sends transaction_id as a query param", async () => {
231
+ const client = createTestClient();
232
+ mockTokenResponse();
233
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
234
+ await client.batchInsert("users", [{ name: "A" }], {
235
+ transactionId: "tx_123",
236
+ });
237
+ const [url, init] = mockFetch.mock.calls[1];
238
+ (0, vitest_1.expect)(init.method).toBe("POST");
239
+ const parsed = new URL(url);
240
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/insert/users");
241
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
242
+ });
243
+ (0, vitest_1.it)("batchUpdate sends transaction_id as a query param", async () => {
244
+ const client = createTestClient();
245
+ mockTokenResponse();
246
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
247
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
248
+ transactionId: "tx_123",
249
+ });
250
+ const [url, init] = mockFetch.mock.calls[1];
251
+ (0, vitest_1.expect)(init.method).toBe("PUT");
252
+ const parsed = new URL(url);
253
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/update/users");
254
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
255
+ });
256
+ (0, vitest_1.it)("batchDelete sends transaction_id as a query param", async () => {
257
+ const client = createTestClient();
258
+ mockTokenResponse();
259
+ mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
260
+ await client.batchDelete("users", ["id_1", "id_2"], undefined, {
261
+ transactionId: "tx_123",
262
+ });
263
+ const [url, init] = mockFetch.mock.calls[1];
264
+ (0, vitest_1.expect)(init.method).toBe("DELETE");
265
+ const parsed = new URL(url);
266
+ (0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/delete/users");
267
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
268
+ });
269
+ (0, vitest_1.it)("batch ops without transaction_id send no such query param (additive)", async () => {
270
+ const client = createTestClient();
271
+ mockTokenResponse();
272
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
273
+ // Legacy call sites: no options object at all.
274
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
275
+ const [url] = mockFetch.mock.calls[1];
276
+ const parsed = new URL(url);
277
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBeNull();
278
+ (0, vitest_1.expect)(parsed.search).toBe("");
279
+ });
230
280
  });
231
281
  // ============================================================================
232
282
  // KV Store Tests
@@ -2503,6 +2553,19 @@ function mockErrorResponse(status, message) {
2503
2553
  // URLSearchParams, so they are out of scope here.
2504
2554
  // ============================================================================
2505
2555
  (0, vitest_1.describe)("EkoDBClient URL path segment encoding", () => {
2556
+ (0, vitest_1.it)("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
2557
+ // A tag with query-reserved characters must be percent-encoded, not
2558
+ // concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
2559
+ // splits into tags="a" plus a smuggled `injected=1` query param.
2560
+ const client = createTestClient();
2561
+ mockTokenResponse();
2562
+ mockJsonResponse([]);
2563
+ await client.listUserFunctions(["a&injected=1", "b"]);
2564
+ const [url] = mockFetch.mock.calls[1];
2565
+ const parsed = new URL(url);
2566
+ (0, vitest_1.expect)(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
2567
+ (0, vitest_1.expect)(parsed.searchParams.has("injected")).toBe(false);
2568
+ });
2506
2569
  (0, vitest_1.it)("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
2507
2570
  const client = createTestClient();
2508
2571
  mockTokenResponse();
@@ -2582,3 +2645,29 @@ function mockErrorResponse(status, message) {
2582
2645
  (0, vitest_1.expect)(url).toContain("/api/chat/sess%231/messages");
2583
2646
  });
2584
2647
  });
2648
+ (0, vitest_1.describe)("request timeout", () => {
2649
+ (0, vitest_1.it)("defaults to a 30s request timeout", () => {
2650
+ (0, vitest_1.expect)(client_1.DEFAULT_REQUEST_TIMEOUT_MS).toBe(30000);
2651
+ });
2652
+ (0, vitest_1.it)("aborts a request that exceeds the configured timeout", async () => {
2653
+ const client = new client_1.EkoDBClient({
2654
+ baseURL: "http://localhost:8080",
2655
+ apiKey: "test-api-key",
2656
+ format: client_1.SerializationFormat.Json,
2657
+ shouldRetry: false,
2658
+ timeout: 20,
2659
+ });
2660
+ // Token fetch resolves immediately...
2661
+ mockTokenResponse();
2662
+ // ...but the actual request hangs until the request-timeout signal aborts
2663
+ // it. The mock honors the AbortSignal the client attaches, rejecting with
2664
+ // its reason (a "TimeoutError" DOMException) exactly like real fetch.
2665
+ mockFetch.mockImplementationOnce((_url, opts) => new Promise((_resolve, reject) => {
2666
+ const signal = opts.signal;
2667
+ signal?.addEventListener("abort", () => {
2668
+ reject(signal.reason ?? new DOMException("timed out", "TimeoutError"));
2669
+ });
2670
+ }));
2671
+ await (0, vitest_1.expect)(client.find("users")).rejects.toThrow(/timed out after 20ms/);
2672
+ });
2673
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
9
- import { EkoDBClient, SerializationFormat, extractRecordId } from "./client";
9
+ import {
10
+ EkoDBClient,
11
+ SerializationFormat,
12
+ extractRecordId,
13
+ DEFAULT_REQUEST_TIMEOUT_MS,
14
+ } from "./client";
10
15
  import { SearchQueryBuilder } from "./search";
11
16
 
12
17
  // Mock fetch globally
@@ -313,6 +318,72 @@ describe("EkoDBClient batch operations", () => {
313
318
 
314
319
  expect(result.successful).toHaveLength(2);
315
320
  });
321
+
322
+ it("batchInsert sends transaction_id as a query param", async () => {
323
+ const client = createTestClient();
324
+
325
+ mockTokenResponse();
326
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
327
+
328
+ await client.batchInsert("users", [{ name: "A" }], {
329
+ transactionId: "tx_123",
330
+ });
331
+
332
+ const [url, init] = mockFetch.mock.calls[1];
333
+ expect(init.method).toBe("POST");
334
+ const parsed = new URL(url as string);
335
+ expect(parsed.pathname).toBe("/api/batch/insert/users");
336
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
337
+ });
338
+
339
+ it("batchUpdate sends transaction_id as a query param", async () => {
340
+ const client = createTestClient();
341
+
342
+ mockTokenResponse();
343
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
344
+
345
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
346
+ transactionId: "tx_123",
347
+ });
348
+
349
+ const [url, init] = mockFetch.mock.calls[1];
350
+ expect(init.method).toBe("PUT");
351
+ const parsed = new URL(url as string);
352
+ expect(parsed.pathname).toBe("/api/batch/update/users");
353
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
354
+ });
355
+
356
+ it("batchDelete sends transaction_id as a query param", async () => {
357
+ const client = createTestClient();
358
+
359
+ mockTokenResponse();
360
+ mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
361
+
362
+ await client.batchDelete("users", ["id_1", "id_2"], undefined, {
363
+ transactionId: "tx_123",
364
+ });
365
+
366
+ const [url, init] = mockFetch.mock.calls[1];
367
+ expect(init.method).toBe("DELETE");
368
+ const parsed = new URL(url as string);
369
+ expect(parsed.pathname).toBe("/api/batch/delete/users");
370
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
371
+ });
372
+
373
+ it("batch ops without transaction_id send no such query param (additive)", async () => {
374
+ const client = createTestClient();
375
+
376
+ mockTokenResponse();
377
+ mockJsonResponse({ successful: ["id_1"], failed: [] });
378
+
379
+ // Legacy call sites: no options object at all.
380
+ await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
381
+
382
+ const [url] = mockFetch.mock.calls[1];
383
+ const parsed = new URL(url as string);
384
+ expect(parsed.searchParams.get("transaction_id")).toBeNull();
385
+ expect(parsed.search).toBe("");
386
+ });
316
387
  });
317
388
 
318
389
  // ============================================================================
@@ -3281,6 +3352,23 @@ describe("extractRecordId", () => {
3281
3352
  // ============================================================================
3282
3353
 
3283
3354
  describe("EkoDBClient URL path segment encoding", () => {
3355
+ it("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
3356
+ // A tag with query-reserved characters must be percent-encoded, not
3357
+ // concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
3358
+ // splits into tags="a" plus a smuggled `injected=1` query param.
3359
+ const client = createTestClient();
3360
+
3361
+ mockTokenResponse();
3362
+ mockJsonResponse([]);
3363
+
3364
+ await client.listUserFunctions(["a&injected=1", "b"]);
3365
+
3366
+ const [url] = mockFetch.mock.calls[1];
3367
+ const parsed = new URL(url as string);
3368
+ expect(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
3369
+ expect(parsed.searchParams.has("injected")).toBe(false);
3370
+ });
3371
+
3284
3372
  it("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
3285
3373
  const client = createTestClient();
3286
3374
 
@@ -3395,3 +3483,38 @@ describe("EkoDBClient URL path segment encoding", () => {
3395
3483
  expect(url as string).toContain("/api/chat/sess%231/messages");
3396
3484
  });
3397
3485
  });
3486
+
3487
+ describe("request timeout", () => {
3488
+ it("defaults to a 30s request timeout", () => {
3489
+ expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(30000);
3490
+ });
3491
+
3492
+ it("aborts a request that exceeds the configured timeout", async () => {
3493
+ const client = new EkoDBClient({
3494
+ baseURL: "http://localhost:8080",
3495
+ apiKey: "test-api-key",
3496
+ format: SerializationFormat.Json,
3497
+ shouldRetry: false,
3498
+ timeout: 20,
3499
+ });
3500
+
3501
+ // Token fetch resolves immediately...
3502
+ mockTokenResponse();
3503
+ // ...but the actual request hangs until the request-timeout signal aborts
3504
+ // it. The mock honors the AbortSignal the client attaches, rejecting with
3505
+ // its reason (a "TimeoutError" DOMException) exactly like real fetch.
3506
+ mockFetch.mockImplementationOnce(
3507
+ (_url: string, opts: RequestInit) =>
3508
+ new Promise((_resolve, reject) => {
3509
+ const signal = opts.signal as AbortSignal | undefined;
3510
+ signal?.addEventListener("abort", () => {
3511
+ reject(
3512
+ signal.reason ?? new DOMException("timed out", "TimeoutError"),
3513
+ );
3514
+ });
3515
+ }),
3516
+ );
3517
+
3518
+ await expect(client.find("users")).rejects.toThrow(/timed out after 20ms/);
3519
+ });
3520
+ });
package/src/client.ts CHANGED
@@ -37,6 +37,13 @@ export interface RateLimitInfo {
37
37
  /**
38
38
  * Client configuration options
39
39
  */
40
+ /**
41
+ * Default per-request timeout (ms) for REST calls, matching the Rust and Go
42
+ * clients' 30s default. Applied to every non-streaming request; long-lived
43
+ * streaming/SSE calls are intentionally not bounded by it.
44
+ */
45
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
46
+
40
47
  export interface ClientConfig {
41
48
  /** Base URL of the ekoDB server */
42
49
  baseURL: string;
@@ -48,6 +55,13 @@ export interface ClientConfig {
48
55
  maxRetries?: number;
49
56
  /** Serialization format (default: MessagePack for best performance, use Json for debugging) */
50
57
  format?: SerializationFormat;
58
+ /**
59
+ * Per-request timeout in milliseconds for REST calls (default: 30000).
60
+ * A slow or unreachable server aborts with a timeout error instead of hanging
61
+ * the caller. Streaming/SSE calls are not bounded by it. Matches the Rust and
62
+ * Go clients.
63
+ */
64
+ timeout?: number;
51
65
  }
52
66
 
53
67
  /**
@@ -419,6 +433,7 @@ export class EkoDBClient {
419
433
  private shouldRetry: boolean;
420
434
  private maxRetries: number;
421
435
  private format: SerializationFormat;
436
+ private requestTimeoutMs: number;
422
437
  private rateLimitInfo: RateLimitInfo | null = null;
423
438
 
424
439
  constructor(config: string | ClientConfig, apiKey?: string) {
@@ -431,12 +446,14 @@ export class EkoDBClient {
431
446
  this.shouldRetry = true;
432
447
  this.maxRetries = 3;
433
448
  this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
449
+ this.requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
434
450
  } else {
435
451
  this.baseURL = stripTrailingSlashes(config.baseURL);
436
452
  this.apiKey = config.apiKey;
437
453
  this.shouldRetry = config.shouldRetry ?? true;
438
454
  this.maxRetries = config.maxRetries ?? 3;
439
455
  this.format = config.format ?? SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
456
+ this.requestTimeoutMs = config.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
440
457
  }
441
458
  }
442
459
 
@@ -471,6 +488,7 @@ export class EkoDBClient {
471
488
  method: "POST",
472
489
  headers: { "Content-Type": "application/json" },
473
490
  body: JSON.stringify({ api_key: this.apiKey }),
491
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
474
492
  });
475
493
 
476
494
  if (!response.ok) {
@@ -683,6 +701,10 @@ export class EkoDBClient {
683
701
  "Content-Type": contentType,
684
702
  Accept: contentType,
685
703
  },
704
+ // Bound every REST request so a slow/unreachable server aborts instead of
705
+ // hanging the caller. A fresh signal per attempt is correct here because
706
+ // makeRequest recurses on retry, rebuilding `options` each time.
707
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
686
708
  };
687
709
 
688
710
  if (data) {
@@ -757,6 +779,20 @@ export class EkoDBClient {
757
779
  const text = await response.text();
758
780
  throw new Error(`Request failed with status ${response.status}: ${text}`);
759
781
  } catch (error) {
782
+ // A fired request-timeout (AbortSignal.timeout) surfaces as a DOMException
783
+ // named "TimeoutError" (or "AbortError" in some runtimes). Convert it to a
784
+ // clear error and do NOT retry — retrying would multiply the wait beyond
785
+ // the configured bound.
786
+ const errName =
787
+ error && typeof error === "object" && "name" in error
788
+ ? (error as { name?: string }).name
789
+ : undefined;
790
+ if (errName === "TimeoutError" || errName === "AbortError") {
791
+ throw new Error(
792
+ `Request timed out after ${this.requestTimeoutMs}ms: ${method} ${path}`,
793
+ );
794
+ }
795
+
760
796
  // Handle network errors with retry
761
797
  if (
762
798
  error instanceof TypeError &&
@@ -1051,40 +1087,77 @@ export class EkoDBClient {
1051
1087
 
1052
1088
  /**
1053
1089
  * Batch update multiple documents
1090
+ * @param collection - Collection name
1091
+ * @param updates - Array of updates ({ id, data, bypassRipple? })
1092
+ * @param options - Optional parameters (bypassRipple, transactionId). When
1093
+ * `transactionId` is set, the batch is staged into that MVCC transaction
1094
+ * (sent as the `transaction_id` query param) instead of committed
1095
+ * immediately — mirrors the single-record update.
1054
1096
  */
1055
1097
  async batchUpdate(
1056
1098
  collection: string,
1057
1099
  updates: Array<{ id: string; data: Record; bypassRipple?: boolean }>,
1100
+ options?: BatchUpdateOptions,
1058
1101
  ): Promise<BatchOperationResult> {
1102
+ const params = new URLSearchParams();
1103
+ if (options?.bypassRipple !== undefined) {
1104
+ params.append("bypass_ripple", String(options.bypassRipple));
1105
+ }
1106
+ if (options?.transactionId) {
1107
+ params.append("transaction_id", options.transactionId);
1108
+ }
1109
+
1059
1110
  const formattedUpdates = updates.map((u) => ({
1060
1111
  id: u.id,
1061
1112
  data: u.data,
1062
1113
  bypass_ripple: u.bypassRipple,
1063
1114
  }));
1064
- return this.makeRequest<BatchOperationResult>(
1065
- "PUT",
1066
- `/api/batch/update/${encodeURIComponent(collection)}`,
1067
- { updates: formattedUpdates },
1068
- );
1115
+ const url = params.toString()
1116
+ ? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
1117
+ : `/api/batch/update/${encodeURIComponent(collection)}`;
1118
+
1119
+ return this.makeRequest<BatchOperationResult>("PUT", url, {
1120
+ updates: formattedUpdates,
1121
+ });
1069
1122
  }
1070
1123
 
1071
1124
  /**
1072
1125
  * Batch delete multiple documents
1126
+ * @param collection - Collection name
1127
+ * @param ids - Document IDs to delete
1128
+ * @param bypassRipple - Optional flag to bypass ripple propagation (legacy
1129
+ * positional form, kept for back-compat)
1130
+ * @param options - Optional parameters (bypassRipple, transactionId). When
1131
+ * `transactionId` is set, the batch is staged into that MVCC transaction
1132
+ * (sent as the `transaction_id` query param) instead of committed
1133
+ * immediately — mirrors the single-record delete.
1073
1134
  */
1074
1135
  async batchDelete(
1075
1136
  collection: string,
1076
1137
  ids: string[],
1077
1138
  bypassRipple?: boolean,
1139
+ options?: BatchDeleteOptions,
1078
1140
  ): Promise<BatchOperationResult> {
1141
+ // bypass_ripple is sent per-item in the body (existing behavior). The
1142
+ // effective value prefers the explicit positional arg, falling back to the
1143
+ // options object so callers can use either form.
1144
+ const effectiveBypassRipple =
1145
+ bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
1146
+
1147
+ const params = new URLSearchParams();
1148
+ if (options?.transactionId) {
1149
+ params.append("transaction_id", options.transactionId);
1150
+ }
1151
+
1079
1152
  const deletes = ids.map((id) => ({
1080
1153
  id: id,
1081
- bypass_ripple: bypassRipple,
1154
+ bypass_ripple: effectiveBypassRipple,
1082
1155
  }));
1083
- return this.makeRequest<BatchOperationResult>(
1084
- "DELETE",
1085
- `/api/batch/delete/${encodeURIComponent(collection)}`,
1086
- { deletes },
1087
- );
1156
+ const url = params.toString()
1157
+ ? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
1158
+ : `/api/batch/delete/${encodeURIComponent(collection)}`;
1159
+
1160
+ return this.makeRequest<BatchOperationResult>("DELETE", url, { deletes });
1088
1161
  }
1089
1162
 
1090
1163
  /**
@@ -2449,7 +2522,11 @@ export class EkoDBClient {
2449
2522
  * List all functions, optionally filtered by tags
2450
2523
  */
2451
2524
  async listFunctions(tags?: string[]): Promise<UserFunction[]> {
2452
- const params = tags ? `?tags=${tags.join(",")}` : "";
2525
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
2526
+ // containing query-reserved characters can't smuggle extra params.
2527
+ const params = tags
2528
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
2529
+ : "";
2453
2530
  return this.makeRequest<UserFunction[]>("GET", `/api/functions${params}`);
2454
2531
  }
2455
2532
 
@@ -2529,7 +2606,11 @@ export class EkoDBClient {
2529
2606
  * @returns Array of user functions
2530
2607
  */
2531
2608
  async listUserFunctions(tags?: string[]): Promise<UserFunction[]> {
2532
- const params = tags ? `?tags=${tags.join(",")}` : "";
2609
+ // URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
2610
+ // containing query-reserved characters can't smuggle extra params.
2611
+ const params = tags
2612
+ ? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
2613
+ : "";
2533
2614
  return this.makeRequest<UserFunction[]>(
2534
2615
  "GET",
2535
2616
  `/api/functions${params}`,