@ekodb/ekodb-client 0.20.0 → 0.21.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.
@@ -250,6 +250,12 @@ function mockErrorResponse(status, message) {
250
250
  mockJsonResponse({ deleted: true });
251
251
  await (0, vitest_1.expect)(client.kvDelete("my_key")).resolves.not.toThrow();
252
252
  });
253
+ (0, vitest_1.it)("clears KV store", async () => {
254
+ const client = createTestClient();
255
+ mockTokenResponse();
256
+ mockJsonResponse({ message: "success" });
257
+ await (0, vitest_1.expect)(client.kvClear()).resolves.not.toThrow();
258
+ });
253
259
  (0, vitest_1.it)("checks KV exists", async () => {
254
260
  const client = createTestClient();
255
261
  mockTokenResponse();
@@ -294,6 +300,13 @@ function mockErrorResponse(status, message) {
294
300
  (0, vitest_1.expect)(result).toContain("users");
295
301
  (0, vitest_1.expect)(result).toHaveLength(3);
296
302
  });
303
+ (0, vitest_1.it)("lists user collections (excludes internal)", async () => {
304
+ const client = createTestClient();
305
+ mockTokenResponse();
306
+ mockJsonResponse({ collections: ["users", "posts"] });
307
+ const result = await client.listUserCollections();
308
+ (0, vitest_1.expect)(result).toEqual(["users", "posts"]);
309
+ });
297
310
  (0, vitest_1.it)("deletes collection", async () => {
298
311
  const client = createTestClient();
299
312
  mockTokenResponse();
@@ -723,6 +736,64 @@ function mockErrorResponse(status, message) {
723
736
  (0, vitest_1.expect)(result).toBeDefined();
724
737
  });
725
738
  });
739
+ (0, vitest_1.describe)("bypass_ripple on the transactional read path", () => {
740
+ (0, vitest_1.it)("findById sends bypass_ripple AND transaction_id together (query params)", async () => {
741
+ const client = createTestClient();
742
+ mockTokenResponse();
743
+ mockJsonResponse({ id: "user_123", name: "Alice" });
744
+ await client.findById("users", "user_123", {
745
+ bypassRipple: true,
746
+ transactionId: "tx_123",
747
+ });
748
+ const [url, init] = mockFetch.mock.calls[1];
749
+ (0, vitest_1.expect)(init.method).toBe("GET");
750
+ const parsed = new URL(url);
751
+ (0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
752
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
753
+ });
754
+ (0, vitest_1.it)("findById sends bypass_ripple=false explicitly alongside transaction_id", async () => {
755
+ const client = createTestClient();
756
+ mockTokenResponse();
757
+ mockJsonResponse({ id: "user_123", name: "Alice" });
758
+ await client.findById("users", "user_123", {
759
+ bypassRipple: false,
760
+ transactionId: "tx_123",
761
+ });
762
+ const [url] = mockFetch.mock.calls[1];
763
+ const parsed = new URL(url);
764
+ (0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("false");
765
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
766
+ });
767
+ (0, vitest_1.it)("find sends bypass_ripple AND transaction_id together as query params", async () => {
768
+ const client = createTestClient();
769
+ mockTokenResponse();
770
+ mockJsonResponse([{ id: "user_1", name: "Alice" }]);
771
+ await client.find("users", { limit: 10 }, { bypassRipple: true, transactionId: "tx_123" });
772
+ const [url, init] = mockFetch.mock.calls[1];
773
+ (0, vitest_1.expect)(init.method).toBe("POST");
774
+ const parsed = new URL(url);
775
+ // bypass_ripple is a query param (like every other method), not in the body.
776
+ (0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
777
+ (0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
778
+ const body = JSON.parse(init.body);
779
+ (0, vitest_1.expect)(body.bypass_ripple).toBeUndefined();
780
+ (0, vitest_1.expect)(body.limit).toBe(10);
781
+ });
782
+ (0, vitest_1.it)("find hoists bypass_ripple from the query object into the query string", async () => {
783
+ const client = createTestClient();
784
+ mockTokenResponse();
785
+ mockJsonResponse([]);
786
+ // A query object carrying bypass_ripple, as QueryBuilder.bypassRipple() builds.
787
+ await client.find("users", { limit: 5, bypass_ripple: true });
788
+ const [url, init] = mockFetch.mock.calls[1];
789
+ const parsed = new URL(url);
790
+ // Hoisted to the query string, removed from the body.
791
+ (0, vitest_1.expect)(parsed.searchParams.get("bypass_ripple")).toBe("true");
792
+ const body = JSON.parse(init.body);
793
+ (0, vitest_1.expect)(body.bypass_ripple).toBeUndefined();
794
+ (0, vitest_1.expect)(body.limit).toBe(5);
795
+ });
796
+ });
726
797
  // ============================================================================
727
798
  // Convenience Methods Tests
728
799
  // ============================================================================
@@ -1340,6 +1411,16 @@ function mockErrorResponse(status, message) {
1340
1411
  const dataCall = calls[1];
1341
1412
  (0, vitest_1.expect)(dataCall[0]).toBe("http://localhost:8080/api/find/users/123");
1342
1413
  });
1414
+ (0, vitest_1.it)("appends transaction_id when a transactionId is given", async () => {
1415
+ const client = createTestClient();
1416
+ mockTokenResponse();
1417
+ mockJsonResponse({ id: "123", name: "Alice" });
1418
+ await client.findByIdWithProjection("users", "123", ["name"], undefined, "txn-abc");
1419
+ const calls = global.fetch.mock.calls;
1420
+ const dataCall = calls[1];
1421
+ (0, vitest_1.expect)(dataCall[0]).toContain("transaction_id=txn-abc");
1422
+ (0, vitest_1.expect)(dataCall[0]).toContain("select_fields=name");
1423
+ });
1343
1424
  });
1344
1425
  // ============================================================================
1345
1426
  // Goal CRUD Tests
@@ -2376,3 +2457,92 @@ function mockErrorResponse(status, message) {
2376
2457
  (0, vitest_1.expect)((0, client_1.extractRecordId)({ _id: "underscore" })).toBe("underscore");
2377
2458
  });
2378
2459
  });
2460
+ // ============================================================================
2461
+ // URL Path Segment Encoding Tests
2462
+ //
2463
+ // Every caller-supplied path segment (collection, id, function label, chat
2464
+ // model/provider, session/message ids, etc.) must be percent-encoded so a
2465
+ // reserved char (`/`, space, `#`, `?`) can't break the URL. This matches the
2466
+ // Rust and Go clients. Query parameters are NOT path segments and go through
2467
+ // URLSearchParams, so they are out of scope here.
2468
+ // ============================================================================
2469
+ (0, vitest_1.describe)("EkoDBClient URL path segment encoding", () => {
2470
+ (0, vitest_1.it)("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
2471
+ const client = createTestClient();
2472
+ mockTokenResponse();
2473
+ mockJsonResponse({ id: "a/b", name: "Alice" });
2474
+ await client.findById("users", "a/b");
2475
+ const [url] = mockFetch.mock.calls[1];
2476
+ (0, vitest_1.expect)(url).toContain("/api/find/users/a%2Fb");
2477
+ (0, vitest_1.expect)(url).not.toContain("/api/find/users/a/b");
2478
+ });
2479
+ (0, vitest_1.it)("findById encodes a reserved-char collection", async () => {
2480
+ const client = createTestClient();
2481
+ mockTokenResponse();
2482
+ mockJsonResponse({ id: "user_123" });
2483
+ await client.findById("my coll", "user_123");
2484
+ const [url] = mockFetch.mock.calls[1];
2485
+ (0, vitest_1.expect)(url).toContain("/api/find/my%20coll/user_123");
2486
+ });
2487
+ (0, vitest_1.it)("findById leaves a normal id unchanged (/api/find/users/123)", async () => {
2488
+ const client = createTestClient();
2489
+ mockTokenResponse();
2490
+ mockJsonResponse({ id: "123", name: "Alice" });
2491
+ await client.findById("users", "123");
2492
+ const [url] = mockFetch.mock.calls[1];
2493
+ (0, vitest_1.expect)(url).toContain("/api/find/users/123");
2494
+ (0, vitest_1.expect)(url).not.toContain("%2F");
2495
+ (0, vitest_1.expect)(url).not.toContain("%20");
2496
+ });
2497
+ (0, vitest_1.it)("callFunction encodes a label containing a slash (anthropic/claude)", async () => {
2498
+ const client = createTestClient();
2499
+ mockTokenResponse();
2500
+ mockJsonResponse({ result: { ok: true } });
2501
+ await client.callFunction("anthropic/claude", {});
2502
+ const [url] = mockFetch.mock.calls[1];
2503
+ (0, vitest_1.expect)(url).toContain("/api/functions/anthropic%2Fclaude");
2504
+ (0, vitest_1.expect)(url).not.toContain("/api/functions/anthropic/claude");
2505
+ });
2506
+ (0, vitest_1.it)("getUserFunction encodes a label containing reserved chars", async () => {
2507
+ const client = createTestClient();
2508
+ mockTokenResponse();
2509
+ mockJsonResponse({ label: "items get/by id" });
2510
+ await client.getUserFunction("items get/by id");
2511
+ const [url] = mockFetch.mock.calls[1];
2512
+ (0, vitest_1.expect)(url).toContain("/api/functions/items%20get%2Fby%20id");
2513
+ });
2514
+ (0, vitest_1.it)("getChatModel encodes a provider containing a slash (anthropic/claude)", async () => {
2515
+ const client = createTestClient();
2516
+ mockTokenResponse();
2517
+ mockJsonResponse(["claude-3"]);
2518
+ await client.getChatModel("anthropic/claude");
2519
+ const [url] = mockFetch.mock.calls[1];
2520
+ (0, vitest_1.expect)(url).toContain("/api/chat_models/anthropic%2Fclaude");
2521
+ (0, vitest_1.expect)(url).not.toContain("/api/chat_models/anthropic/claude");
2522
+ });
2523
+ (0, vitest_1.it)("deleteCollection encodes a reserved-char collection", async () => {
2524
+ const client = createTestClient();
2525
+ mockTokenResponse();
2526
+ mockJsonResponse({ status: "deleted" });
2527
+ await client.deleteCollection("a/b");
2528
+ const [url] = mockFetch.mock.calls[1];
2529
+ (0, vitest_1.expect)(url).toContain("/api/collections/a%2Fb");
2530
+ });
2531
+ (0, vitest_1.it)("getChatSessionMessages encodes the session id path segment", async () => {
2532
+ const client = createTestClient();
2533
+ mockTokenResponse();
2534
+ mockJsonResponse({ messages: [], total: 0 });
2535
+ await client.getChatSessionMessages("sess/1");
2536
+ const [url] = mockFetch.mock.calls[1];
2537
+ (0, vitest_1.expect)(url).toContain("/api/chat/sess%2F1/messages");
2538
+ (0, vitest_1.expect)(url).not.toContain("/api/chat/sess/1/messages");
2539
+ });
2540
+ (0, vitest_1.it)("chatMessage encodes the session id and leaves a normal one unchanged", async () => {
2541
+ const client = createTestClient();
2542
+ mockTokenResponse();
2543
+ mockJsonResponse({ message_id: "m1", content: "hi" });
2544
+ await client.chatMessage("sess#1", { message: "hi" });
2545
+ const [url] = mockFetch.mock.calls[1];
2546
+ (0, vitest_1.expect)(url).toContain("/api/chat/sess%231/messages");
2547
+ });
2548
+ });
@@ -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
- return new Promise((resolve) => {
34
- ws.once("message", (data) => {
35
- resolve(JSON.parse(data.toString()));
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",