@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.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
9
9
  import { EkoDBClient, SerializationFormat, extractRecordId } from "./client";
10
+ import { SearchQueryBuilder } from "./search";
10
11
 
11
12
  // Mock fetch globally
12
13
  const mockFetch = vi.fn();
@@ -350,6 +351,15 @@ describe("EkoDBClient KV store", () => {
350
351
  await expect(client.kvDelete("my_key")).resolves.not.toThrow();
351
352
  });
352
353
 
354
+ it("clears KV store", async () => {
355
+ const client = createTestClient();
356
+
357
+ mockTokenResponse();
358
+ mockJsonResponse({ message: "success" });
359
+
360
+ await expect(client.kvClear()).resolves.not.toThrow();
361
+ });
362
+
353
363
  it("checks KV exists", async () => {
354
364
  const client = createTestClient();
355
365
 
@@ -414,6 +424,17 @@ describe("EkoDBClient collections", () => {
414
424
  expect(result).toHaveLength(3);
415
425
  });
416
426
 
427
+ it("lists user collections (excludes internal)", async () => {
428
+ const client = createTestClient();
429
+
430
+ mockTokenResponse();
431
+ mockJsonResponse({ collections: ["users", "posts"] });
432
+
433
+ const result = await client.listUserCollections();
434
+
435
+ expect(result).toEqual(["users", "posts"]);
436
+ });
437
+
417
438
  it("deletes collection", async () => {
418
439
  const client = createTestClient();
419
440
 
@@ -469,6 +490,25 @@ describe("EkoDBClient search", () => {
469
490
  });
470
491
 
471
492
  // Note: textSearch and hybridSearch require specific mock setup - covered by integration tests
493
+
494
+ it("builds a vector search query with a metadata filter", () => {
495
+ const filter = {
496
+ type: "Condition",
497
+ content: { field: "category", operator: "Eq", value: "ml" },
498
+ };
499
+ const query = new SearchQueryBuilder("test")
500
+ .vector([0.1, 0.2, 0.3])
501
+ .filters(filter)
502
+ .build();
503
+
504
+ expect(query.filters).toEqual(filter);
505
+ expect(query.vector).toEqual([0.1, 0.2, 0.3]);
506
+ });
507
+
508
+ it("omits filters when not set", () => {
509
+ const query = new SearchQueryBuilder("test").build();
510
+ expect(query.filters).toBeUndefined();
511
+ });
472
512
  });
473
513
 
474
514
  // ============================================================================
@@ -994,6 +1034,84 @@ describe("EkoDBClient transaction status", () => {
994
1034
  });
995
1035
  });
996
1036
 
1037
+ describe("bypass_ripple on the transactional read path", () => {
1038
+ it("findById sends bypass_ripple AND transaction_id together (query params)", async () => {
1039
+ const client = createTestClient();
1040
+
1041
+ mockTokenResponse();
1042
+ mockJsonResponse({ id: "user_123", name: "Alice" });
1043
+
1044
+ await client.findById("users", "user_123", {
1045
+ bypassRipple: true,
1046
+ transactionId: "tx_123",
1047
+ });
1048
+
1049
+ const [url, init] = mockFetch.mock.calls[1];
1050
+ expect(init.method).toBe("GET");
1051
+ const parsed = new URL(url as string);
1052
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1053
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1054
+ });
1055
+
1056
+ it("findById sends bypass_ripple=false explicitly alongside transaction_id", async () => {
1057
+ const client = createTestClient();
1058
+
1059
+ mockTokenResponse();
1060
+ mockJsonResponse({ id: "user_123", name: "Alice" });
1061
+
1062
+ await client.findById("users", "user_123", {
1063
+ bypassRipple: false,
1064
+ transactionId: "tx_123",
1065
+ });
1066
+
1067
+ const [url] = mockFetch.mock.calls[1];
1068
+ const parsed = new URL(url as string);
1069
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("false");
1070
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1071
+ });
1072
+
1073
+ it("find sends bypass_ripple AND transaction_id together as query params", async () => {
1074
+ const client = createTestClient();
1075
+
1076
+ mockTokenResponse();
1077
+ mockJsonResponse([{ id: "user_1", name: "Alice" }]);
1078
+
1079
+ await client.find(
1080
+ "users",
1081
+ { limit: 10 },
1082
+ { bypassRipple: true, transactionId: "tx_123" },
1083
+ );
1084
+
1085
+ const [url, init] = mockFetch.mock.calls[1];
1086
+ expect(init.method).toBe("POST");
1087
+ const parsed = new URL(url as string);
1088
+ // bypass_ripple is a query param (like every other method), not in the body.
1089
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1090
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1091
+ const body = JSON.parse(init.body as string);
1092
+ expect(body.bypass_ripple).toBeUndefined();
1093
+ expect(body.limit).toBe(10);
1094
+ });
1095
+
1096
+ it("find hoists bypass_ripple from the query object into the query string", async () => {
1097
+ const client = createTestClient();
1098
+
1099
+ mockTokenResponse();
1100
+ mockJsonResponse([]);
1101
+
1102
+ // A query object carrying bypass_ripple, as QueryBuilder.bypassRipple() builds.
1103
+ await client.find("users", { limit: 5, bypass_ripple: true } as any);
1104
+
1105
+ const [url, init] = mockFetch.mock.calls[1];
1106
+ const parsed = new URL(url as string);
1107
+ // Hoisted to the query string, removed from the body.
1108
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1109
+ const body = JSON.parse(init.body as string);
1110
+ expect(body.bypass_ripple).toBeUndefined();
1111
+ expect(body.limit).toBe(5);
1112
+ });
1113
+ });
1114
+
997
1115
  // ============================================================================
998
1116
  // Convenience Methods Tests
999
1117
  // ============================================================================
@@ -1821,6 +1939,26 @@ describe("findByIdWithProjection", () => {
1821
1939
  const dataCall = calls[1];
1822
1940
  expect(dataCall[0]).toBe("http://localhost:8080/api/find/users/123");
1823
1941
  });
1942
+
1943
+ it("appends transaction_id when a transactionId is given", async () => {
1944
+ const client = createTestClient();
1945
+
1946
+ mockTokenResponse();
1947
+ mockJsonResponse({ id: "123", name: "Alice" });
1948
+
1949
+ await client.findByIdWithProjection(
1950
+ "users",
1951
+ "123",
1952
+ ["name"],
1953
+ undefined,
1954
+ "txn-abc",
1955
+ );
1956
+
1957
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
1958
+ const dataCall = calls[1];
1959
+ expect(dataCall[0]).toContain("transaction_id=txn-abc");
1960
+ expect(dataCall[0]).toContain("select_fields=name");
1961
+ });
1824
1962
  });
1825
1963
 
1826
1964
  // ============================================================================
@@ -2931,6 +3069,29 @@ describe("submitChatToolResult", () => {
2931
3069
  });
2932
3070
  });
2933
3071
 
3072
+ // ============================================================================
3073
+ // submitChatToolKeepalive Tests
3074
+ // ============================================================================
3075
+
3076
+ describe("submitChatToolKeepalive", () => {
3077
+ it("sends keepalive to correct endpoint", async () => {
3078
+ const client = createTestClient();
3079
+ mockTokenResponse();
3080
+ mockJsonResponse({});
3081
+
3082
+ await client.submitChatToolKeepalive("chat-123", "call-456");
3083
+
3084
+ expect(mockFetch).toHaveBeenCalledTimes(2);
3085
+ const call = mockFetch.mock.calls[1];
3086
+ expect(call[0]).toContain("/api/chat/chat-123/tool-result");
3087
+ const body = JSON.parse(call[1].body);
3088
+ expect(body.call_id).toBe("call-456");
3089
+ expect(body.keepalive).toBe(true);
3090
+ expect(body.success).toBeUndefined();
3091
+ expect(body.result).toBeUndefined();
3092
+ });
3093
+ });
3094
+
2934
3095
  // ============================================================================
2935
3096
  // subscribeSSE Tests
2936
3097
  // ============================================================================
@@ -3108,3 +3269,129 @@ describe("extractRecordId", () => {
3108
3269
  expect(extractRecordId({ _id: "underscore" })).toBe("underscore");
3109
3270
  });
3110
3271
  });
3272
+
3273
+ // ============================================================================
3274
+ // URL Path Segment Encoding Tests
3275
+ //
3276
+ // Every caller-supplied path segment (collection, id, function label, chat
3277
+ // model/provider, session/message ids, etc.) must be percent-encoded so a
3278
+ // reserved char (`/`, space, `#`, `?`) can't break the URL. This matches the
3279
+ // Rust and Go clients. Query parameters are NOT path segments and go through
3280
+ // URLSearchParams, so they are out of scope here.
3281
+ // ============================================================================
3282
+
3283
+ describe("EkoDBClient URL path segment encoding", () => {
3284
+ it("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
3285
+ const client = createTestClient();
3286
+
3287
+ mockTokenResponse();
3288
+ mockJsonResponse({ id: "a/b", name: "Alice" });
3289
+
3290
+ await client.findById("users", "a/b");
3291
+
3292
+ const [url] = mockFetch.mock.calls[1];
3293
+ expect(url as string).toContain("/api/find/users/a%2Fb");
3294
+ expect(url as string).not.toContain("/api/find/users/a/b");
3295
+ });
3296
+
3297
+ it("findById encodes a reserved-char collection", async () => {
3298
+ const client = createTestClient();
3299
+
3300
+ mockTokenResponse();
3301
+ mockJsonResponse({ id: "user_123" });
3302
+
3303
+ await client.findById("my coll", "user_123");
3304
+
3305
+ const [url] = mockFetch.mock.calls[1];
3306
+ expect(url as string).toContain("/api/find/my%20coll/user_123");
3307
+ });
3308
+
3309
+ it("findById leaves a normal id unchanged (/api/find/users/123)", async () => {
3310
+ const client = createTestClient();
3311
+
3312
+ mockTokenResponse();
3313
+ mockJsonResponse({ id: "123", name: "Alice" });
3314
+
3315
+ await client.findById("users", "123");
3316
+
3317
+ const [url] = mockFetch.mock.calls[1];
3318
+ expect(url as string).toContain("/api/find/users/123");
3319
+ expect(url as string).not.toContain("%2F");
3320
+ expect(url as string).not.toContain("%20");
3321
+ });
3322
+
3323
+ it("callFunction encodes a label containing a slash (anthropic/claude)", async () => {
3324
+ const client = createTestClient();
3325
+
3326
+ mockTokenResponse();
3327
+ mockJsonResponse({ result: { ok: true } });
3328
+
3329
+ await client.callFunction("anthropic/claude", {});
3330
+
3331
+ const [url] = mockFetch.mock.calls[1];
3332
+ expect(url as string).toContain("/api/functions/anthropic%2Fclaude");
3333
+ expect(url as string).not.toContain("/api/functions/anthropic/claude");
3334
+ });
3335
+
3336
+ it("getUserFunction encodes a label containing reserved chars", async () => {
3337
+ const client = createTestClient();
3338
+
3339
+ mockTokenResponse();
3340
+ mockJsonResponse({ label: "items get/by id" });
3341
+
3342
+ await client.getUserFunction("items get/by id");
3343
+
3344
+ const [url] = mockFetch.mock.calls[1];
3345
+ expect(url as string).toContain("/api/functions/items%20get%2Fby%20id");
3346
+ });
3347
+
3348
+ it("getChatModel encodes a provider containing a slash (anthropic/claude)", async () => {
3349
+ const client = createTestClient();
3350
+
3351
+ mockTokenResponse();
3352
+ mockJsonResponse(["claude-3"]);
3353
+
3354
+ await client.getChatModel("anthropic/claude");
3355
+
3356
+ const [url] = mockFetch.mock.calls[1];
3357
+ expect(url as string).toContain("/api/chat_models/anthropic%2Fclaude");
3358
+ expect(url as string).not.toContain("/api/chat_models/anthropic/claude");
3359
+ });
3360
+
3361
+ it("deleteCollection encodes a reserved-char collection", async () => {
3362
+ const client = createTestClient();
3363
+
3364
+ mockTokenResponse();
3365
+ mockJsonResponse({ status: "deleted" });
3366
+
3367
+ await client.deleteCollection("a/b");
3368
+
3369
+ const [url] = mockFetch.mock.calls[1];
3370
+ expect(url as string).toContain("/api/collections/a%2Fb");
3371
+ });
3372
+
3373
+ it("getChatSessionMessages encodes the session id path segment", async () => {
3374
+ const client = createTestClient();
3375
+
3376
+ mockTokenResponse();
3377
+ mockJsonResponse({ messages: [], total: 0 });
3378
+
3379
+ await client.getChatSessionMessages("sess/1");
3380
+
3381
+ const [url] = mockFetch.mock.calls[1];
3382
+ expect(url as string).toContain("/api/chat/sess%2F1/messages");
3383
+ expect(url as string).not.toContain("/api/chat/sess/1/messages");
3384
+ });
3385
+
3386
+ it("chatMessage encodes the session id and leaves a normal one unchanged", async () => {
3387
+ const client = createTestClient();
3388
+
3389
+ mockTokenResponse();
3390
+ mockJsonResponse({ message_id: "m1", content: "hi" });
3391
+
3392
+ await client.chatMessage("sess#1", { message: "hi" });
3393
+
3394
+ const [url] = mockFetch.mock.calls[1];
3395
+ expect(url as string).toContain("/api/chat/sess%231/messages");
3396
+ });
3397
+ });