@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 +31 -3
- package/dist/client.js +80 -9
- package/dist/client.test.js +89 -0
- package/package.json +1 -1
- package/src/client.test.ts +124 -1
- package/src/client.ts +94 -13
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
|
-
}
|
|
536
|
+
}>, options?: BatchUpdateOptions): Promise<BatchOperationResult>;
|
|
517
537
|
/**
|
|
518
538
|
* Batch delete multiple documents
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
677
|
+
bypass_ripple: effectiveBypassRipple,
|
|
618
678
|
}));
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/client.test.js
CHANGED
|
@@ -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
package/src/client.test.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
9
|
-
import {
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
`/api/batch/update/${encodeURIComponent(collection)}
|
|
1067
|
-
|
|
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:
|
|
1154
|
+
bypass_ripple: effectiveBypassRipple,
|
|
1082
1155
|
}));
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
`/api/batch/delete/${encodeURIComponent(collection)}
|
|
1086
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|