@ekodb/ekodb-client 0.19.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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
9
- import { EkoDBClient, SerializationFormat } from "./client";
9
+ import { EkoDBClient, SerializationFormat, extractRecordId } from "./client";
10
10
 
11
11
  // Mock fetch globally
12
12
  const mockFetch = vi.fn();
@@ -350,6 +350,15 @@ describe("EkoDBClient KV store", () => {
350
350
  await expect(client.kvDelete("my_key")).resolves.not.toThrow();
351
351
  });
352
352
 
353
+ it("clears KV store", async () => {
354
+ const client = createTestClient();
355
+
356
+ mockTokenResponse();
357
+ mockJsonResponse({ message: "success" });
358
+
359
+ await expect(client.kvClear()).resolves.not.toThrow();
360
+ });
361
+
353
362
  it("checks KV exists", async () => {
354
363
  const client = createTestClient();
355
364
 
@@ -414,6 +423,17 @@ describe("EkoDBClient collections", () => {
414
423
  expect(result).toHaveLength(3);
415
424
  });
416
425
 
426
+ it("lists user collections (excludes internal)", async () => {
427
+ const client = createTestClient();
428
+
429
+ mockTokenResponse();
430
+ mockJsonResponse({ collections: ["users", "posts"] });
431
+
432
+ const result = await client.listUserCollections();
433
+
434
+ expect(result).toEqual(["users", "posts"]);
435
+ });
436
+
417
437
  it("deletes collection", async () => {
418
438
  const client = createTestClient();
419
439
 
@@ -994,6 +1014,84 @@ describe("EkoDBClient transaction status", () => {
994
1014
  });
995
1015
  });
996
1016
 
1017
+ describe("bypass_ripple on the transactional read path", () => {
1018
+ it("findById sends bypass_ripple AND transaction_id together (query params)", async () => {
1019
+ const client = createTestClient();
1020
+
1021
+ mockTokenResponse();
1022
+ mockJsonResponse({ id: "user_123", name: "Alice" });
1023
+
1024
+ await client.findById("users", "user_123", {
1025
+ bypassRipple: true,
1026
+ transactionId: "tx_123",
1027
+ });
1028
+
1029
+ const [url, init] = mockFetch.mock.calls[1];
1030
+ expect(init.method).toBe("GET");
1031
+ const parsed = new URL(url as string);
1032
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1033
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1034
+ });
1035
+
1036
+ it("findById sends bypass_ripple=false explicitly alongside transaction_id", async () => {
1037
+ const client = createTestClient();
1038
+
1039
+ mockTokenResponse();
1040
+ mockJsonResponse({ id: "user_123", name: "Alice" });
1041
+
1042
+ await client.findById("users", "user_123", {
1043
+ bypassRipple: false,
1044
+ transactionId: "tx_123",
1045
+ });
1046
+
1047
+ const [url] = mockFetch.mock.calls[1];
1048
+ const parsed = new URL(url as string);
1049
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("false");
1050
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1051
+ });
1052
+
1053
+ it("find sends bypass_ripple AND transaction_id together as query params", async () => {
1054
+ const client = createTestClient();
1055
+
1056
+ mockTokenResponse();
1057
+ mockJsonResponse([{ id: "user_1", name: "Alice" }]);
1058
+
1059
+ await client.find(
1060
+ "users",
1061
+ { limit: 10 },
1062
+ { bypassRipple: true, transactionId: "tx_123" },
1063
+ );
1064
+
1065
+ const [url, init] = mockFetch.mock.calls[1];
1066
+ expect(init.method).toBe("POST");
1067
+ const parsed = new URL(url as string);
1068
+ // bypass_ripple is a query param (like every other method), not in the body.
1069
+ expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
1070
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1071
+ const body = JSON.parse(init.body as string);
1072
+ expect(body.bypass_ripple).toBeUndefined();
1073
+ expect(body.limit).toBe(10);
1074
+ });
1075
+
1076
+ it("find hoists bypass_ripple from the query object into the query string", async () => {
1077
+ const client = createTestClient();
1078
+
1079
+ mockTokenResponse();
1080
+ mockJsonResponse([]);
1081
+
1082
+ // A query object carrying bypass_ripple, as QueryBuilder.bypassRipple() builds.
1083
+ await client.find("users", { limit: 5, bypass_ripple: true } as any);
1084
+
1085
+ const [url, init] = mockFetch.mock.calls[1];
1086
+ const parsed = new URL(url as string);
1087
+ // Hoisted to the query string, removed from the body.
1088
+ expect(parsed.searchParams.get("bypass_ripple")).toBe("true");
1089
+ const body = JSON.parse(init.body as string);
1090
+ expect(body.bypass_ripple).toBeUndefined();
1091
+ expect(body.limit).toBe(5);
1092
+ });
1093
+ });
1094
+
997
1095
  // ============================================================================
998
1096
  // Convenience Methods Tests
999
1097
  // ============================================================================
@@ -1821,6 +1919,26 @@ describe("findByIdWithProjection", () => {
1821
1919
  const dataCall = calls[1];
1822
1920
  expect(dataCall[0]).toBe("http://localhost:8080/api/find/users/123");
1823
1921
  });
1922
+
1923
+ it("appends transaction_id when a transactionId is given", async () => {
1924
+ const client = createTestClient();
1925
+
1926
+ mockTokenResponse();
1927
+ mockJsonResponse({ id: "123", name: "Alice" });
1928
+
1929
+ await client.findByIdWithProjection(
1930
+ "users",
1931
+ "123",
1932
+ ["name"],
1933
+ undefined,
1934
+ "txn-abc",
1935
+ );
1936
+
1937
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
1938
+ const dataCall = calls[1];
1939
+ expect(dataCall[0]).toContain("transaction_id=txn-abc");
1940
+ expect(dataCall[0]).toContain("select_fields=name");
1941
+ });
1824
1942
  });
1825
1943
 
1826
1944
  // ============================================================================
@@ -2432,6 +2550,80 @@ describe("EkoDBClient chatMessageStream", () => {
2432
2550
  expect(dataCall[1]?.method).toBe("POST");
2433
2551
  expect(dataCall[1]?.headers?.Accept).toBe("text/event-stream");
2434
2552
  });
2553
+
2554
+ it("sends a resolved Bearer token, not a Promise (regression #124)", async () => {
2555
+ const client = createTestClient();
2556
+ mockTokenResponse();
2557
+
2558
+ mockFetch.mockResolvedValueOnce({
2559
+ ok: true,
2560
+ status: 200,
2561
+ text: async () => 'data: {"token":"ok"}\n',
2562
+ headers: new Headers({ "content-type": "text/event-stream" }),
2563
+ });
2564
+
2565
+ client.chatMessageStream("chat_789", { message: "Test" });
2566
+ await new Promise((resolve) => setTimeout(resolve, 50));
2567
+
2568
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
2569
+ const auth = (calls[1][1]?.headers as Record<string, string>)
2570
+ ?.Authorization;
2571
+ expect(auth).toBe("Bearer test-jwt-token");
2572
+ expect(auth).not.toContain("[object Promise]");
2573
+ });
2574
+
2575
+ it("streams SSE events incrementally from response.body, reassembling split lines (regression #125)", async () => {
2576
+ const client = createTestClient();
2577
+ mockTokenResponse();
2578
+
2579
+ // A data line is deliberately split across chunk boundaries to exercise the
2580
+ // incremental buffer (the old code buffered the whole body via text()).
2581
+ const enc = new TextEncoder();
2582
+ const chunks = [
2583
+ 'data: {"token":"He"}\nda',
2584
+ 'ta: {"token":"llo"}\n',
2585
+ 'data: {"content":"Hello","message_id":"m1","execution_time_ms":1}\n',
2586
+ ];
2587
+ const body = new ReadableStream({
2588
+ start(controller) {
2589
+ for (const c of chunks) controller.enqueue(enc.encode(c));
2590
+ controller.close();
2591
+ },
2592
+ });
2593
+
2594
+ mockFetch.mockResolvedValueOnce({
2595
+ ok: true,
2596
+ status: 200,
2597
+ body,
2598
+ text: async () => chunks.join(""),
2599
+ headers: new Headers({ "content-type": "text/event-stream" }),
2600
+ });
2601
+
2602
+ const events: any[] = [];
2603
+ const stream = client.chatMessageStream("chat_s", { message: "Hi" });
2604
+ stream.on("event", (evt: any) => events.push(evt));
2605
+ await new Promise((resolve) => setTimeout(resolve, 50));
2606
+
2607
+ expect(
2608
+ events.filter((e) => e.type === "chunk").map((e) => e.content),
2609
+ ).toEqual(["He", "llo"]);
2610
+ expect(events[events.length - 1].type).toBe("end");
2611
+ expect(events[events.length - 1].messageId).toBe("m1");
2612
+ });
2613
+ });
2614
+
2615
+ describe("EkoDBClient retry backoff", () => {
2616
+ it("backoffSeconds grows, caps at 5s, and jitters within [d/2, d] (#126)", () => {
2617
+ const client = createTestClient() as any;
2618
+ for (let attempt = 0; attempt < 10; attempt++) {
2619
+ const d = Math.min(0.2 * 2 ** attempt, 5);
2620
+ for (let i = 0; i < 50; i++) {
2621
+ const v = client.backoffSeconds(attempt);
2622
+ expect(v).toBeGreaterThanOrEqual(d / 2);
2623
+ expect(v).toBeLessThanOrEqual(d);
2624
+ }
2625
+ }
2626
+ });
2435
2627
  });
2436
2628
 
2437
2629
  // ============================================================================
@@ -2959,3 +3151,204 @@ describe("subscribeSSE", () => {
2959
3151
  expect(errors[0]).toContain("401");
2960
3152
  });
2961
3153
  });
3154
+
3155
+ describe("baseURL normalization", () => {
3156
+ it("strips a trailing slash so request URLs have no double slash", async () => {
3157
+ mockTokenResponse();
3158
+ const client = new EkoDBClient({
3159
+ baseURL: "http://localhost:8080/",
3160
+ apiKey: "test-api-key",
3161
+ format: SerializationFormat.Json,
3162
+ shouldRetry: false,
3163
+ });
3164
+ await client.init();
3165
+
3166
+ const url = mockFetch.mock.calls[0][0] as string;
3167
+ expect(url).toBe("http://localhost:8080/api/auth/token");
3168
+ expect(url).not.toContain("//api");
3169
+ });
3170
+
3171
+ it("leaves a URL without a trailing slash unchanged", async () => {
3172
+ mockTokenResponse();
3173
+ const client = new EkoDBClient({
3174
+ baseURL: "http://localhost:8080",
3175
+ apiKey: "test-api-key",
3176
+ format: SerializationFormat.Json,
3177
+ shouldRetry: false,
3178
+ });
3179
+ await client.init();
3180
+
3181
+ const url = mockFetch.mock.calls[0][0] as string;
3182
+ expect(url).toBe("http://localhost:8080/api/auth/token");
3183
+ });
3184
+ });
3185
+
3186
+ describe("extractRecordId", () => {
3187
+ it("returns a plain string id", () => {
3188
+ expect(extractRecordId({ id: "abc" })).toBe("abc");
3189
+ });
3190
+
3191
+ it("unwraps a genuine typed wrapper id", () => {
3192
+ expect(extractRecordId({ id: { type: "String", value: "abc" } })).toBe(
3193
+ "abc",
3194
+ );
3195
+ });
3196
+
3197
+ it("stringifies a wrapped numeric id", () => {
3198
+ expect(extractRecordId({ id: { type: "Integer", value: 123 } })).toBe(
3199
+ "123",
3200
+ );
3201
+ });
3202
+
3203
+ it("does not treat a user object with a value key (no type) as the id", () => {
3204
+ // Regression for #134: { value: 1, currency: "USD" } is a user object,
3205
+ // not a typed wrapper, so it must not be unwrapped into the id.
3206
+ expect(
3207
+ extractRecordId({ id: { value: 1, currency: "USD" } }),
3208
+ ).toBeUndefined();
3209
+ });
3210
+
3211
+ it("prefers an alias candidate over id", () => {
3212
+ expect(extractRecordId({ users_id: "u1", id: "x" }, ["users_id"])).toBe(
3213
+ "u1",
3214
+ );
3215
+ });
3216
+
3217
+ it("ignores a non-wrapper alias object and falls back to id", () => {
3218
+ expect(
3219
+ extractRecordId({ users_id: { value: 7, label: "lvl" }, id: "real" }, [
3220
+ "users_id",
3221
+ ]),
3222
+ ).toBe("real");
3223
+ });
3224
+
3225
+ it("falls back to _id", () => {
3226
+ expect(extractRecordId({ _id: "underscore" })).toBe("underscore");
3227
+ });
3228
+ });
3229
+
3230
+ // ============================================================================
3231
+ // URL Path Segment Encoding Tests
3232
+ //
3233
+ // Every caller-supplied path segment (collection, id, function label, chat
3234
+ // model/provider, session/message ids, etc.) must be percent-encoded so a
3235
+ // reserved char (`/`, space, `#`, `?`) can't break the URL. This matches the
3236
+ // Rust and Go clients. Query parameters are NOT path segments and go through
3237
+ // URLSearchParams, so they are out of scope here.
3238
+ // ============================================================================
3239
+
3240
+ describe("EkoDBClient URL path segment encoding", () => {
3241
+ it("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
3242
+ const client = createTestClient();
3243
+
3244
+ mockTokenResponse();
3245
+ mockJsonResponse({ id: "a/b", name: "Alice" });
3246
+
3247
+ await client.findById("users", "a/b");
3248
+
3249
+ const [url] = mockFetch.mock.calls[1];
3250
+ expect(url as string).toContain("/api/find/users/a%2Fb");
3251
+ expect(url as string).not.toContain("/api/find/users/a/b");
3252
+ });
3253
+
3254
+ it("findById encodes a reserved-char collection", async () => {
3255
+ const client = createTestClient();
3256
+
3257
+ mockTokenResponse();
3258
+ mockJsonResponse({ id: "user_123" });
3259
+
3260
+ await client.findById("my coll", "user_123");
3261
+
3262
+ const [url] = mockFetch.mock.calls[1];
3263
+ expect(url as string).toContain("/api/find/my%20coll/user_123");
3264
+ });
3265
+
3266
+ it("findById leaves a normal id unchanged (/api/find/users/123)", async () => {
3267
+ const client = createTestClient();
3268
+
3269
+ mockTokenResponse();
3270
+ mockJsonResponse({ id: "123", name: "Alice" });
3271
+
3272
+ await client.findById("users", "123");
3273
+
3274
+ const [url] = mockFetch.mock.calls[1];
3275
+ expect(url as string).toContain("/api/find/users/123");
3276
+ expect(url as string).not.toContain("%2F");
3277
+ expect(url as string).not.toContain("%20");
3278
+ });
3279
+
3280
+ it("callFunction encodes a label containing a slash (anthropic/claude)", async () => {
3281
+ const client = createTestClient();
3282
+
3283
+ mockTokenResponse();
3284
+ mockJsonResponse({ result: { ok: true } });
3285
+
3286
+ await client.callFunction("anthropic/claude", {});
3287
+
3288
+ const [url] = mockFetch.mock.calls[1];
3289
+ expect(url as string).toContain("/api/functions/anthropic%2Fclaude");
3290
+ expect(url as string).not.toContain("/api/functions/anthropic/claude");
3291
+ });
3292
+
3293
+ it("getUserFunction encodes a label containing reserved chars", async () => {
3294
+ const client = createTestClient();
3295
+
3296
+ mockTokenResponse();
3297
+ mockJsonResponse({ label: "items get/by id" });
3298
+
3299
+ await client.getUserFunction("items get/by id");
3300
+
3301
+ const [url] = mockFetch.mock.calls[1];
3302
+ expect(url as string).toContain("/api/functions/items%20get%2Fby%20id");
3303
+ });
3304
+
3305
+ it("getChatModel encodes a provider containing a slash (anthropic/claude)", async () => {
3306
+ const client = createTestClient();
3307
+
3308
+ mockTokenResponse();
3309
+ mockJsonResponse(["claude-3"]);
3310
+
3311
+ await client.getChatModel("anthropic/claude");
3312
+
3313
+ const [url] = mockFetch.mock.calls[1];
3314
+ expect(url as string).toContain("/api/chat_models/anthropic%2Fclaude");
3315
+ expect(url as string).not.toContain("/api/chat_models/anthropic/claude");
3316
+ });
3317
+
3318
+ it("deleteCollection encodes a reserved-char collection", async () => {
3319
+ const client = createTestClient();
3320
+
3321
+ mockTokenResponse();
3322
+ mockJsonResponse({ status: "deleted" });
3323
+
3324
+ await client.deleteCollection("a/b");
3325
+
3326
+ const [url] = mockFetch.mock.calls[1];
3327
+ expect(url as string).toContain("/api/collections/a%2Fb");
3328
+ });
3329
+
3330
+ it("getChatSessionMessages encodes the session id path segment", async () => {
3331
+ const client = createTestClient();
3332
+
3333
+ mockTokenResponse();
3334
+ mockJsonResponse({ messages: [], total: 0 });
3335
+
3336
+ await client.getChatSessionMessages("sess/1");
3337
+
3338
+ const [url] = mockFetch.mock.calls[1];
3339
+ expect(url as string).toContain("/api/chat/sess%2F1/messages");
3340
+ expect(url as string).not.toContain("/api/chat/sess/1/messages");
3341
+ });
3342
+
3343
+ it("chatMessage encodes the session id and leaves a normal one unchanged", async () => {
3344
+ const client = createTestClient();
3345
+
3346
+ mockTokenResponse();
3347
+ mockJsonResponse({ message_id: "m1", content: "hi" });
3348
+
3349
+ await client.chatMessage("sess#1", { message: "hi" });
3350
+
3351
+ const [url] = mockFetch.mock.calls[1];
3352
+ expect(url as string).toContain("/api/chat/sess%231/messages");
3353
+ });
3354
+ });