@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/src/client.test.ts
CHANGED
|
@@ -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
|
+
});
|