@ekodb/ekodb-client 0.20.0 → 0.22.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.
- package/README.md +28 -0
- package/dist/client.d.ts +101 -12
- package/dist/client.js +269 -56
- package/dist/client.test.js +206 -0
- package/dist/search.d.ts +12 -0
- package/dist/search.js +9 -0
- package/dist/websocket.test.js +159 -5
- package/package.json +1 -1
- package/src/client.test.ts +287 -0
- package/src/client.ts +367 -65
- package/src/search.ts +18 -0
- package/src/websocket.test.ts +225 -5
package/dist/client.test.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
const vitest_1 = require("vitest");
|
|
10
10
|
const client_1 = require("./client");
|
|
11
|
+
const search_1 = require("./search");
|
|
11
12
|
// Mock fetch globally
|
|
12
13
|
const mockFetch = vitest_1.vi.fn();
|
|
13
14
|
global.fetch = mockFetch;
|
|
@@ -250,6 +251,12 @@ function mockErrorResponse(status, message) {
|
|
|
250
251
|
mockJsonResponse({ deleted: true });
|
|
251
252
|
await (0, vitest_1.expect)(client.kvDelete("my_key")).resolves.not.toThrow();
|
|
252
253
|
});
|
|
254
|
+
(0, vitest_1.it)("clears KV store", async () => {
|
|
255
|
+
const client = createTestClient();
|
|
256
|
+
mockTokenResponse();
|
|
257
|
+
mockJsonResponse({ message: "success" });
|
|
258
|
+
await (0, vitest_1.expect)(client.kvClear()).resolves.not.toThrow();
|
|
259
|
+
});
|
|
253
260
|
(0, vitest_1.it)("checks KV exists", async () => {
|
|
254
261
|
const client = createTestClient();
|
|
255
262
|
mockTokenResponse();
|
|
@@ -294,6 +301,13 @@ function mockErrorResponse(status, message) {
|
|
|
294
301
|
(0, vitest_1.expect)(result).toContain("users");
|
|
295
302
|
(0, vitest_1.expect)(result).toHaveLength(3);
|
|
296
303
|
});
|
|
304
|
+
(0, vitest_1.it)("lists user collections (excludes internal)", async () => {
|
|
305
|
+
const client = createTestClient();
|
|
306
|
+
mockTokenResponse();
|
|
307
|
+
mockJsonResponse({ collections: ["users", "posts"] });
|
|
308
|
+
const result = await client.listUserCollections();
|
|
309
|
+
(0, vitest_1.expect)(result).toEqual(["users", "posts"]);
|
|
310
|
+
});
|
|
297
311
|
(0, vitest_1.it)("deletes collection", async () => {
|
|
298
312
|
const client = createTestClient();
|
|
299
313
|
mockTokenResponse();
|
|
@@ -332,6 +346,22 @@ function mockErrorResponse(status, message) {
|
|
|
332
346
|
(0, vitest_1.expect)(result.total).toBe(2);
|
|
333
347
|
});
|
|
334
348
|
// Note: textSearch and hybridSearch require specific mock setup - covered by integration tests
|
|
349
|
+
(0, vitest_1.it)("builds a vector search query with a metadata filter", () => {
|
|
350
|
+
const filter = {
|
|
351
|
+
type: "Condition",
|
|
352
|
+
content: { field: "category", operator: "Eq", value: "ml" },
|
|
353
|
+
};
|
|
354
|
+
const query = new search_1.SearchQueryBuilder("test")
|
|
355
|
+
.vector([0.1, 0.2, 0.3])
|
|
356
|
+
.filters(filter)
|
|
357
|
+
.build();
|
|
358
|
+
(0, vitest_1.expect)(query.filters).toEqual(filter);
|
|
359
|
+
(0, vitest_1.expect)(query.vector).toEqual([0.1, 0.2, 0.3]);
|
|
360
|
+
});
|
|
361
|
+
(0, vitest_1.it)("omits filters when not set", () => {
|
|
362
|
+
const query = new search_1.SearchQueryBuilder("test").build();
|
|
363
|
+
(0, vitest_1.expect)(query.filters).toBeUndefined();
|
|
364
|
+
});
|
|
335
365
|
});
|
|
336
366
|
// ============================================================================
|
|
337
367
|
// KV Batch Operations Tests
|
|
@@ -723,6 +753,64 @@ function mockErrorResponse(status, message) {
|
|
|
723
753
|
(0, vitest_1.expect)(result).toBeDefined();
|
|
724
754
|
});
|
|
725
755
|
});
|
|
756
|
+
(0, vitest_1.describe)("bypass_ripple on the transactional read path", () => {
|
|
757
|
+
(0, vitest_1.it)("findById sends bypass_ripple AND transaction_id together (query params)", async () => {
|
|
758
|
+
const client = createTestClient();
|
|
759
|
+
mockTokenResponse();
|
|
760
|
+
mockJsonResponse({ id: "user_123", name: "Alice" });
|
|
761
|
+
await client.findById("users", "user_123", {
|
|
762
|
+
bypassRipple: true,
|
|
763
|
+
transactionId: "tx_123",
|
|
764
|
+
});
|
|
765
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
766
|
+
(0, vitest_1.expect)(init.method).toBe("GET");
|
|
767
|
+
const parsed = new URL(url);
|
|
768
|
+
(0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
|
|
769
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
770
|
+
});
|
|
771
|
+
(0, vitest_1.it)("findById sends bypass_ripple=false explicitly alongside transaction_id", async () => {
|
|
772
|
+
const client = createTestClient();
|
|
773
|
+
mockTokenResponse();
|
|
774
|
+
mockJsonResponse({ id: "user_123", name: "Alice" });
|
|
775
|
+
await client.findById("users", "user_123", {
|
|
776
|
+
bypassRipple: false,
|
|
777
|
+
transactionId: "tx_123",
|
|
778
|
+
});
|
|
779
|
+
const [url] = mockFetch.mock.calls[1];
|
|
780
|
+
const parsed = new URL(url);
|
|
781
|
+
(0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("false");
|
|
782
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
783
|
+
});
|
|
784
|
+
(0, vitest_1.it)("find sends bypass_ripple AND transaction_id together as query params", async () => {
|
|
785
|
+
const client = createTestClient();
|
|
786
|
+
mockTokenResponse();
|
|
787
|
+
mockJsonResponse([{ id: "user_1", name: "Alice" }]);
|
|
788
|
+
await client.find("users", { limit: 10 }, { bypassRipple: true, transactionId: "tx_123" });
|
|
789
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
790
|
+
(0, vitest_1.expect)(init.method).toBe("POST");
|
|
791
|
+
const parsed = new URL(url);
|
|
792
|
+
// bypass_ripple is a query param (like every other method), not in the body.
|
|
793
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
794
|
+
(0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
|
|
795
|
+
const body = JSON.parse(init.body);
|
|
796
|
+
(0, vitest_1.expect)(body.bypass_ripple).toBeUndefined();
|
|
797
|
+
(0, vitest_1.expect)(body.limit).toBe(10);
|
|
798
|
+
});
|
|
799
|
+
(0, vitest_1.it)("find hoists bypass_ripple from the query object into the query string", async () => {
|
|
800
|
+
const client = createTestClient();
|
|
801
|
+
mockTokenResponse();
|
|
802
|
+
mockJsonResponse([]);
|
|
803
|
+
// A query object carrying bypass_ripple, as QueryBuilder.bypassRipple() builds.
|
|
804
|
+
await client.find("users", { limit: 5, bypass_ripple: true });
|
|
805
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
806
|
+
const parsed = new URL(url);
|
|
807
|
+
// Hoisted to the query string, removed from the body.
|
|
808
|
+
(0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
|
|
809
|
+
const body = JSON.parse(init.body);
|
|
810
|
+
(0, vitest_1.expect)(body.bypass_ripple).toBeUndefined();
|
|
811
|
+
(0, vitest_1.expect)(body.limit).toBe(5);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
726
814
|
// ============================================================================
|
|
727
815
|
// Convenience Methods Tests
|
|
728
816
|
// ============================================================================
|
|
@@ -1340,6 +1428,16 @@ function mockErrorResponse(status, message) {
|
|
|
1340
1428
|
const dataCall = calls[1];
|
|
1341
1429
|
(0, vitest_1.expect)(dataCall[0]).toBe("http://localhost:8080/api/find/users/123");
|
|
1342
1430
|
});
|
|
1431
|
+
(0, vitest_1.it)("appends transaction_id when a transactionId is given", async () => {
|
|
1432
|
+
const client = createTestClient();
|
|
1433
|
+
mockTokenResponse();
|
|
1434
|
+
mockJsonResponse({ id: "123", name: "Alice" });
|
|
1435
|
+
await client.findByIdWithProjection("users", "123", ["name"], undefined, "txn-abc");
|
|
1436
|
+
const calls = global.fetch.mock.calls;
|
|
1437
|
+
const dataCall = calls[1];
|
|
1438
|
+
(0, vitest_1.expect)(dataCall[0]).toContain("transaction_id=txn-abc");
|
|
1439
|
+
(0, vitest_1.expect)(dataCall[0]).toContain("select_fields=name");
|
|
1440
|
+
});
|
|
1343
1441
|
});
|
|
1344
1442
|
// ============================================================================
|
|
1345
1443
|
// Goal CRUD Tests
|
|
@@ -2237,6 +2335,25 @@ function mockErrorResponse(status, message) {
|
|
|
2237
2335
|
});
|
|
2238
2336
|
});
|
|
2239
2337
|
// ============================================================================
|
|
2338
|
+
// submitChatToolKeepalive Tests
|
|
2339
|
+
// ============================================================================
|
|
2340
|
+
(0, vitest_1.describe)("submitChatToolKeepalive", () => {
|
|
2341
|
+
(0, vitest_1.it)("sends keepalive to correct endpoint", async () => {
|
|
2342
|
+
const client = createTestClient();
|
|
2343
|
+
mockTokenResponse();
|
|
2344
|
+
mockJsonResponse({});
|
|
2345
|
+
await client.submitChatToolKeepalive("chat-123", "call-456");
|
|
2346
|
+
(0, vitest_1.expect)(mockFetch).toHaveBeenCalledTimes(2);
|
|
2347
|
+
const call = mockFetch.mock.calls[1];
|
|
2348
|
+
(0, vitest_1.expect)(call[0]).toContain("/api/chat/chat-123/tool-result");
|
|
2349
|
+
const body = JSON.parse(call[1].body);
|
|
2350
|
+
(0, vitest_1.expect)(body.call_id).toBe("call-456");
|
|
2351
|
+
(0, vitest_1.expect)(body.keepalive).toBe(true);
|
|
2352
|
+
(0, vitest_1.expect)(body.success).toBeUndefined();
|
|
2353
|
+
(0, vitest_1.expect)(body.result).toBeUndefined();
|
|
2354
|
+
});
|
|
2355
|
+
});
|
|
2356
|
+
// ============================================================================
|
|
2240
2357
|
// subscribeSSE Tests
|
|
2241
2358
|
// ============================================================================
|
|
2242
2359
|
(0, vitest_1.describe)("subscribeSSE", () => {
|
|
@@ -2376,3 +2493,92 @@ function mockErrorResponse(status, message) {
|
|
|
2376
2493
|
(0, vitest_1.expect)((0, client_1.extractRecordId)({ _id: "underscore" })).toBe("underscore");
|
|
2377
2494
|
});
|
|
2378
2495
|
});
|
|
2496
|
+
// ============================================================================
|
|
2497
|
+
// URL Path Segment Encoding Tests
|
|
2498
|
+
//
|
|
2499
|
+
// Every caller-supplied path segment (collection, id, function label, chat
|
|
2500
|
+
// model/provider, session/message ids, etc.) must be percent-encoded so a
|
|
2501
|
+
// reserved char (`/`, space, `#`, `?`) can't break the URL. This matches the
|
|
2502
|
+
// Rust and Go clients. Query parameters are NOT path segments and go through
|
|
2503
|
+
// URLSearchParams, so they are out of scope here.
|
|
2504
|
+
// ============================================================================
|
|
2505
|
+
(0, vitest_1.describe)("EkoDBClient URL path segment encoding", () => {
|
|
2506
|
+
(0, vitest_1.it)("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
|
|
2507
|
+
const client = createTestClient();
|
|
2508
|
+
mockTokenResponse();
|
|
2509
|
+
mockJsonResponse({ id: "a/b", name: "Alice" });
|
|
2510
|
+
await client.findById("users", "a/b");
|
|
2511
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2512
|
+
(0, vitest_1.expect)(url).toContain("/api/find/users/a%2Fb");
|
|
2513
|
+
(0, vitest_1.expect)(url).not.toContain("/api/find/users/a/b");
|
|
2514
|
+
});
|
|
2515
|
+
(0, vitest_1.it)("findById encodes a reserved-char collection", async () => {
|
|
2516
|
+
const client = createTestClient();
|
|
2517
|
+
mockTokenResponse();
|
|
2518
|
+
mockJsonResponse({ id: "user_123" });
|
|
2519
|
+
await client.findById("my coll", "user_123");
|
|
2520
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2521
|
+
(0, vitest_1.expect)(url).toContain("/api/find/my%20coll/user_123");
|
|
2522
|
+
});
|
|
2523
|
+
(0, vitest_1.it)("findById leaves a normal id unchanged (/api/find/users/123)", async () => {
|
|
2524
|
+
const client = createTestClient();
|
|
2525
|
+
mockTokenResponse();
|
|
2526
|
+
mockJsonResponse({ id: "123", name: "Alice" });
|
|
2527
|
+
await client.findById("users", "123");
|
|
2528
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2529
|
+
(0, vitest_1.expect)(url).toContain("/api/find/users/123");
|
|
2530
|
+
(0, vitest_1.expect)(url).not.toContain("%2F");
|
|
2531
|
+
(0, vitest_1.expect)(url).not.toContain("%20");
|
|
2532
|
+
});
|
|
2533
|
+
(0, vitest_1.it)("callFunction encodes a label containing a slash (anthropic/claude)", async () => {
|
|
2534
|
+
const client = createTestClient();
|
|
2535
|
+
mockTokenResponse();
|
|
2536
|
+
mockJsonResponse({ result: { ok: true } });
|
|
2537
|
+
await client.callFunction("anthropic/claude", {});
|
|
2538
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2539
|
+
(0, vitest_1.expect)(url).toContain("/api/functions/anthropic%2Fclaude");
|
|
2540
|
+
(0, vitest_1.expect)(url).not.toContain("/api/functions/anthropic/claude");
|
|
2541
|
+
});
|
|
2542
|
+
(0, vitest_1.it)("getUserFunction encodes a label containing reserved chars", async () => {
|
|
2543
|
+
const client = createTestClient();
|
|
2544
|
+
mockTokenResponse();
|
|
2545
|
+
mockJsonResponse({ label: "items get/by id" });
|
|
2546
|
+
await client.getUserFunction("items get/by id");
|
|
2547
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2548
|
+
(0, vitest_1.expect)(url).toContain("/api/functions/items%20get%2Fby%20id");
|
|
2549
|
+
});
|
|
2550
|
+
(0, vitest_1.it)("getChatModel encodes a provider containing a slash (anthropic/claude)", async () => {
|
|
2551
|
+
const client = createTestClient();
|
|
2552
|
+
mockTokenResponse();
|
|
2553
|
+
mockJsonResponse(["claude-3"]);
|
|
2554
|
+
await client.getChatModel("anthropic/claude");
|
|
2555
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2556
|
+
(0, vitest_1.expect)(url).toContain("/api/chat_models/anthropic%2Fclaude");
|
|
2557
|
+
(0, vitest_1.expect)(url).not.toContain("/api/chat_models/anthropic/claude");
|
|
2558
|
+
});
|
|
2559
|
+
(0, vitest_1.it)("deleteCollection encodes a reserved-char collection", async () => {
|
|
2560
|
+
const client = createTestClient();
|
|
2561
|
+
mockTokenResponse();
|
|
2562
|
+
mockJsonResponse({ status: "deleted" });
|
|
2563
|
+
await client.deleteCollection("a/b");
|
|
2564
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2565
|
+
(0, vitest_1.expect)(url).toContain("/api/collections/a%2Fb");
|
|
2566
|
+
});
|
|
2567
|
+
(0, vitest_1.it)("getChatSessionMessages encodes the session id path segment", async () => {
|
|
2568
|
+
const client = createTestClient();
|
|
2569
|
+
mockTokenResponse();
|
|
2570
|
+
mockJsonResponse({ messages: [], total: 0 });
|
|
2571
|
+
await client.getChatSessionMessages("sess/1");
|
|
2572
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2573
|
+
(0, vitest_1.expect)(url).toContain("/api/chat/sess%2F1/messages");
|
|
2574
|
+
(0, vitest_1.expect)(url).not.toContain("/api/chat/sess/1/messages");
|
|
2575
|
+
});
|
|
2576
|
+
(0, vitest_1.it)("chatMessage encodes the session id and leaves a normal one unchanged", async () => {
|
|
2577
|
+
const client = createTestClient();
|
|
2578
|
+
mockTokenResponse();
|
|
2579
|
+
mockJsonResponse({ message_id: "m1", content: "hi" });
|
|
2580
|
+
await client.chatMessage("sess#1", { message: "hi" });
|
|
2581
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2582
|
+
(0, vitest_1.expect)(url).toContain("/api/chat/sess%231/messages");
|
|
2583
|
+
});
|
|
2584
|
+
});
|
package/dist/search.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface SearchQuery {
|
|
|
52
52
|
select_fields?: string[];
|
|
53
53
|
/** Exclude these fields from results */
|
|
54
54
|
exclude_fields?: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Metadata pre-filter for text/vector/hybrid search. A canonical QueryExpression
|
|
57
|
+
* (the same shape produced by `QueryBuilder.build().filter`); only records
|
|
58
|
+
* matching the filter are considered as candidates before ranking.
|
|
59
|
+
*/
|
|
60
|
+
filters?: any;
|
|
55
61
|
}
|
|
56
62
|
/**
|
|
57
63
|
* Search result with score and matched fields
|
|
@@ -177,6 +183,12 @@ export declare class SearchQueryBuilder {
|
|
|
177
183
|
* Exclude specific fields from results
|
|
178
184
|
*/
|
|
179
185
|
excludeFields(fields: string[]): this;
|
|
186
|
+
/**
|
|
187
|
+
* Set a metadata pre-filter for text/vector/hybrid search. Accepts a canonical
|
|
188
|
+
* QueryExpression (the same shape produced by `QueryBuilder.build().filter`);
|
|
189
|
+
* only records matching the filter are considered before ranking.
|
|
190
|
+
*/
|
|
191
|
+
filters(filter: any): this;
|
|
180
192
|
/**
|
|
181
193
|
* Build the final SearchQuery object
|
|
182
194
|
*/
|
package/dist/search.js
CHANGED
|
@@ -176,6 +176,15 @@ class SearchQueryBuilder {
|
|
|
176
176
|
this.query.exclude_fields = fields;
|
|
177
177
|
return this;
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Set a metadata pre-filter for text/vector/hybrid search. Accepts a canonical
|
|
181
|
+
* QueryExpression (the same shape produced by `QueryBuilder.build().filter`);
|
|
182
|
+
* only records matching the filter are considered before ranking.
|
|
183
|
+
*/
|
|
184
|
+
filters(filter) {
|
|
185
|
+
this.query.filters = filter;
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
179
188
|
/**
|
|
180
189
|
* Build the final SearchQuery object
|
|
181
190
|
*/
|
package/dist/websocket.test.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
const vitest_1 = require("vitest");
|
|
9
9
|
const ws_1 = require("ws");
|
|
10
|
+
const msgpack_1 = require("@msgpack/msgpack");
|
|
10
11
|
const client_1 = require("./client");
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Test Helpers
|
|
@@ -14,11 +15,48 @@ const client_1 = require("./client");
|
|
|
14
15
|
let wss;
|
|
15
16
|
let port;
|
|
16
17
|
let serverConnections = [];
|
|
18
|
+
// Format the mock server Welcomes during the handshake. Default "json" keeps
|
|
19
|
+
// the existing tests on the text transport they assert against; the binary
|
|
20
|
+
// negotiation test sets "msgpack" before connecting.
|
|
21
|
+
let welcomeFormat = "json";
|
|
22
|
+
const recvState = new Map();
|
|
17
23
|
function startServer() {
|
|
18
24
|
return new Promise((resolve) => {
|
|
19
25
|
wss = new ws_1.WebSocketServer({ port: 0 });
|
|
20
26
|
wss.on("connection", (ws) => {
|
|
21
27
|
serverConnections.push(ws);
|
|
28
|
+
const state = {
|
|
29
|
+
queue: [],
|
|
30
|
+
waiters: [],
|
|
31
|
+
handshakeDone: false,
|
|
32
|
+
lastBinary: false,
|
|
33
|
+
};
|
|
34
|
+
recvState.set(ws, state);
|
|
35
|
+
ws.on("message", (data, isBinary) => {
|
|
36
|
+
let msg;
|
|
37
|
+
try {
|
|
38
|
+
msg = isBinary ? (0, msgpack_1.decode)(data) : JSON.parse(data.toString());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Answer the additive Hello handshake exactly like the real server, and
|
|
44
|
+
// never surface it to the test. The Hello is always text.
|
|
45
|
+
if (!state.handshakeDone && msg?.type === "Hello") {
|
|
46
|
+
state.handshakeDone = true;
|
|
47
|
+
ws.send(JSON.stringify({
|
|
48
|
+
type: "Welcome",
|
|
49
|
+
payload: { format: welcomeFormat },
|
|
50
|
+
}));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
state.lastBinary = isBinary;
|
|
54
|
+
const waiter = state.waiters.shift();
|
|
55
|
+
if (waiter)
|
|
56
|
+
waiter(msg);
|
|
57
|
+
else
|
|
58
|
+
state.queue.push(msg);
|
|
59
|
+
});
|
|
22
60
|
});
|
|
23
61
|
wss.on("listening", () => {
|
|
24
62
|
const addr = wss.address();
|
|
@@ -29,12 +67,15 @@ function startServer() {
|
|
|
29
67
|
function getLastConnection() {
|
|
30
68
|
return serverConnections[serverConnections.length - 1];
|
|
31
69
|
}
|
|
70
|
+
// Resolve with the next real (post-handshake) frame, draining any already
|
|
71
|
+
// buffered. Decoded per the negotiated frame type, so it works for both JSON
|
|
72
|
+
// and msgpack connections without the caller caring which.
|
|
32
73
|
function waitForMessage(ws) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
const state = recvState.get(ws);
|
|
75
|
+
const queued = state.queue.shift();
|
|
76
|
+
if (queued !== undefined)
|
|
77
|
+
return Promise.resolve(queued);
|
|
78
|
+
return new Promise((resolve) => state.waiters.push(resolve));
|
|
38
79
|
}
|
|
39
80
|
// ============================================================================
|
|
40
81
|
// Tests
|
|
@@ -42,6 +83,8 @@ function waitForMessage(ws) {
|
|
|
42
83
|
(0, vitest_1.describe)("WebSocketClient", () => {
|
|
43
84
|
(0, vitest_1.beforeEach)(async () => {
|
|
44
85
|
serverConnections = [];
|
|
86
|
+
recvState.clear();
|
|
87
|
+
welcomeFormat = "json";
|
|
45
88
|
port = await startServer();
|
|
46
89
|
});
|
|
47
90
|
(0, vitest_1.afterEach)(() => {
|
|
@@ -317,6 +360,70 @@ function waitForMessage(ws) {
|
|
|
317
360
|
});
|
|
318
361
|
});
|
|
319
362
|
// --------------------------------------------------------------------------
|
|
363
|
+
// unsubscribe
|
|
364
|
+
// --------------------------------------------------------------------------
|
|
365
|
+
(0, vitest_1.describe)("unsubscribe", () => {
|
|
366
|
+
(0, vitest_1.it)("sends an Unsubscribe frame to the server", async () => {
|
|
367
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
368
|
+
const streamPromise = client.subscribe("orders");
|
|
369
|
+
await new Promise((r) => wss.once("connection", r));
|
|
370
|
+
const ws = getLastConnection();
|
|
371
|
+
const subMsg = await waitForMessage(ws);
|
|
372
|
+
(0, vitest_1.expect)(subMsg.type).toBe("Subscribe");
|
|
373
|
+
ws.send(JSON.stringify({
|
|
374
|
+
type: "Success",
|
|
375
|
+
payload: { message_id: subMsg.messageId, status: "subscribed" },
|
|
376
|
+
}));
|
|
377
|
+
await streamPromise;
|
|
378
|
+
const unsubMsg = waitForMessage(ws);
|
|
379
|
+
client.unsubscribe("orders");
|
|
380
|
+
const sent = await unsubMsg;
|
|
381
|
+
(0, vitest_1.expect)(sent.type).toBe("Unsubscribe");
|
|
382
|
+
(0, vitest_1.expect)(sent.payload.collection).toBe("orders");
|
|
383
|
+
client.close();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
// cancelChat
|
|
387
|
+
// --------------------------------------------------------------------------
|
|
388
|
+
(0, vitest_1.describe)("cancelChat", () => {
|
|
389
|
+
(0, vitest_1.it)("sends CancelChat frame", async () => {
|
|
390
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
391
|
+
// Open the connection via a chat stream first.
|
|
392
|
+
const streamPromise = client.chatSend("chat-1", "test");
|
|
393
|
+
await new Promise((r) => wss.once("connection", r));
|
|
394
|
+
const ws = getLastConnection();
|
|
395
|
+
await waitForMessage(ws); // ChatSend
|
|
396
|
+
await streamPromise;
|
|
397
|
+
const cancelMsg = waitForMessage(ws);
|
|
398
|
+
await client.cancelChat("chat-1");
|
|
399
|
+
const sent = await cancelMsg;
|
|
400
|
+
(0, vitest_1.expect)(sent.type).toBe("CancelChat");
|
|
401
|
+
(0, vitest_1.expect)(sent.payload.chat_id).toBe("chat-1");
|
|
402
|
+
// A correlation id must be attached so a Success ack can't be misrouted by
|
|
403
|
+
// the dispatcher's single-pending fallback.
|
|
404
|
+
(0, vitest_1.expect)(typeof sent.messageId).toBe("string");
|
|
405
|
+
(0, vitest_1.expect)(sent.messageId.length).toBeGreaterThan(0);
|
|
406
|
+
client.close();
|
|
407
|
+
});
|
|
408
|
+
(0, vitest_1.it)("attaches a unique messageId on each CancelChat frame", async () => {
|
|
409
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
410
|
+
const streamPromise = client.chatSend("chat-1", "test");
|
|
411
|
+
await new Promise((r) => wss.once("connection", r));
|
|
412
|
+
const ws = getLastConnection();
|
|
413
|
+
await waitForMessage(ws); // ChatSend
|
|
414
|
+
await streamPromise;
|
|
415
|
+
const first = waitForMessage(ws);
|
|
416
|
+
await client.cancelChat("chat-1");
|
|
417
|
+
const firstSent = await first;
|
|
418
|
+
const second = waitForMessage(ws);
|
|
419
|
+
await client.cancelChat("chat-1");
|
|
420
|
+
const secondSent = await second;
|
|
421
|
+
(0, vitest_1.expect)(firstSent.messageId).toBeTruthy();
|
|
422
|
+
(0, vitest_1.expect)(secondSent.messageId).toBeTruthy();
|
|
423
|
+
(0, vitest_1.expect)(firstSent.messageId).not.toBe(secondSent.messageId);
|
|
424
|
+
client.close();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
320
427
|
// sendToolResult
|
|
321
428
|
// --------------------------------------------------------------------------
|
|
322
429
|
(0, vitest_1.describe)("sendToolResult", () => {
|
|
@@ -673,4 +780,51 @@ function waitForMessage(ws) {
|
|
|
673
780
|
client.close();
|
|
674
781
|
});
|
|
675
782
|
});
|
|
783
|
+
// --------------------------------------------------------------------------
|
|
784
|
+
// Binary (msgpack) negotiation
|
|
785
|
+
// --------------------------------------------------------------------------
|
|
786
|
+
(0, vitest_1.describe)("msgpack negotiation", () => {
|
|
787
|
+
(0, vitest_1.it)("negotiates msgpack and round-trips a binary request/response", async () => {
|
|
788
|
+
welcomeFormat = "msgpack";
|
|
789
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
790
|
+
const resultPromise = client.findAll("users");
|
|
791
|
+
await new Promise((r) => wss.once("connection", r));
|
|
792
|
+
const ws = getLastConnection();
|
|
793
|
+
const msg = await waitForMessage(ws);
|
|
794
|
+
// The request arrived as a binary msgpack frame, decoded to the same
|
|
795
|
+
// object shape as JSON would produce.
|
|
796
|
+
(0, vitest_1.expect)(recvState.get(ws).lastBinary).toBe(true);
|
|
797
|
+
(0, vitest_1.expect)(msg.type).toBe("FindAll");
|
|
798
|
+
(0, vitest_1.expect)(msg.payload.collection).toBe("users");
|
|
799
|
+
// Respond with a binary msgpack frame; the client must decode it
|
|
800
|
+
// transparently.
|
|
801
|
+
ws.send((0, msgpack_1.encode)({
|
|
802
|
+
type: "Success",
|
|
803
|
+
payload: {
|
|
804
|
+
message_id: msg.messageId,
|
|
805
|
+
data: [{ id: "1", name: "Alice" }],
|
|
806
|
+
},
|
|
807
|
+
}));
|
|
808
|
+
const result = await resultPromise;
|
|
809
|
+
(0, vitest_1.expect)(result).toEqual([{ id: "1", name: "Alice" }]);
|
|
810
|
+
client.close();
|
|
811
|
+
});
|
|
812
|
+
(0, vitest_1.it)("stays on JSON text when the server welcomes only json", async () => {
|
|
813
|
+
welcomeFormat = "json";
|
|
814
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
|
|
815
|
+
const resultPromise = client.findAll("users");
|
|
816
|
+
await new Promise((r) => wss.once("connection", r));
|
|
817
|
+
const ws = getLastConnection();
|
|
818
|
+
const msg = await waitForMessage(ws);
|
|
819
|
+
// The request is a JSON text frame, not binary.
|
|
820
|
+
(0, vitest_1.expect)(recvState.get(ws).lastBinary).toBe(false);
|
|
821
|
+
(0, vitest_1.expect)(msg.type).toBe("FindAll");
|
|
822
|
+
ws.send(JSON.stringify({
|
|
823
|
+
type: "Success",
|
|
824
|
+
payload: { message_id: msg.messageId, data: [] },
|
|
825
|
+
}));
|
|
826
|
+
await resultPromise;
|
|
827
|
+
client.close();
|
|
828
|
+
});
|
|
829
|
+
});
|
|
676
830
|
});
|