@ekodb/ekodb-client 0.18.2 → 0.20.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();
@@ -665,6 +665,51 @@ describe("EkoDBClient chat", () => {
665
665
 
666
666
  await expect(client.deleteChatSession("chat_123")).resolves.not.toThrow();
667
667
  });
668
+
669
+ it("compacts chat history", async () => {
670
+ const client = createTestClient();
671
+
672
+ mockTokenResponse();
673
+ mockJsonResponse({
674
+ folded: 12,
675
+ kept_recent: 8,
676
+ summary_chars: 1024,
677
+ summary_message_id: "msg_summary_001",
678
+ already_compact: false,
679
+ });
680
+
681
+ const result = await client.compactChat("chat_123", 8);
682
+
683
+ expect(result).toHaveProperty("folded", 12);
684
+ expect(result).toHaveProperty("kept_recent", 8);
685
+ expect(result).toHaveProperty("summary_message_id", "msg_summary_001");
686
+ expect(result).toHaveProperty("already_compact", false);
687
+
688
+ const [, init] = mockFetch.mock.calls[1];
689
+ expect(init.method).toBe("POST");
690
+ expect(JSON.parse(init.body as string)).toEqual({ keep_recent: 8 });
691
+ });
692
+
693
+ it("compacts chat history without keepRecent", async () => {
694
+ const client = createTestClient();
695
+
696
+ mockTokenResponse();
697
+ mockJsonResponse({
698
+ folded: 0,
699
+ kept_recent: 0,
700
+ summary_chars: 0,
701
+ summary_message_id: null,
702
+ already_compact: true,
703
+ });
704
+
705
+ const result = await client.compactChat("chat_123");
706
+
707
+ expect(result.already_compact).toBe(true);
708
+ expect(result.summary_message_id).toBeNull();
709
+
710
+ const [, init] = mockFetch.mock.calls[1];
711
+ expect(JSON.parse(init.body as string)).toEqual({});
712
+ });
668
713
  });
669
714
 
670
715
  // ============================================================================
@@ -2387,6 +2432,80 @@ describe("EkoDBClient chatMessageStream", () => {
2387
2432
  expect(dataCall[1]?.method).toBe("POST");
2388
2433
  expect(dataCall[1]?.headers?.Accept).toBe("text/event-stream");
2389
2434
  });
2435
+
2436
+ it("sends a resolved Bearer token, not a Promise (regression #124)", async () => {
2437
+ const client = createTestClient();
2438
+ mockTokenResponse();
2439
+
2440
+ mockFetch.mockResolvedValueOnce({
2441
+ ok: true,
2442
+ status: 200,
2443
+ text: async () => 'data: {"token":"ok"}\n',
2444
+ headers: new Headers({ "content-type": "text/event-stream" }),
2445
+ });
2446
+
2447
+ client.chatMessageStream("chat_789", { message: "Test" });
2448
+ await new Promise((resolve) => setTimeout(resolve, 50));
2449
+
2450
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
2451
+ const auth = (calls[1][1]?.headers as Record<string, string>)
2452
+ ?.Authorization;
2453
+ expect(auth).toBe("Bearer test-jwt-token");
2454
+ expect(auth).not.toContain("[object Promise]");
2455
+ });
2456
+
2457
+ it("streams SSE events incrementally from response.body, reassembling split lines (regression #125)", async () => {
2458
+ const client = createTestClient();
2459
+ mockTokenResponse();
2460
+
2461
+ // A data line is deliberately split across chunk boundaries to exercise the
2462
+ // incremental buffer (the old code buffered the whole body via text()).
2463
+ const enc = new TextEncoder();
2464
+ const chunks = [
2465
+ 'data: {"token":"He"}\nda',
2466
+ 'ta: {"token":"llo"}\n',
2467
+ 'data: {"content":"Hello","message_id":"m1","execution_time_ms":1}\n',
2468
+ ];
2469
+ const body = new ReadableStream({
2470
+ start(controller) {
2471
+ for (const c of chunks) controller.enqueue(enc.encode(c));
2472
+ controller.close();
2473
+ },
2474
+ });
2475
+
2476
+ mockFetch.mockResolvedValueOnce({
2477
+ ok: true,
2478
+ status: 200,
2479
+ body,
2480
+ text: async () => chunks.join(""),
2481
+ headers: new Headers({ "content-type": "text/event-stream" }),
2482
+ });
2483
+
2484
+ const events: any[] = [];
2485
+ const stream = client.chatMessageStream("chat_s", { message: "Hi" });
2486
+ stream.on("event", (evt: any) => events.push(evt));
2487
+ await new Promise((resolve) => setTimeout(resolve, 50));
2488
+
2489
+ expect(
2490
+ events.filter((e) => e.type === "chunk").map((e) => e.content),
2491
+ ).toEqual(["He", "llo"]);
2492
+ expect(events[events.length - 1].type).toBe("end");
2493
+ expect(events[events.length - 1].messageId).toBe("m1");
2494
+ });
2495
+ });
2496
+
2497
+ describe("EkoDBClient retry backoff", () => {
2498
+ it("backoffSeconds grows, caps at 5s, and jitters within [d/2, d] (#126)", () => {
2499
+ const client = createTestClient() as any;
2500
+ for (let attempt = 0; attempt < 10; attempt++) {
2501
+ const d = Math.min(0.2 * 2 ** attempt, 5);
2502
+ for (let i = 0; i < 50; i++) {
2503
+ const v = client.backoffSeconds(attempt);
2504
+ expect(v).toBeGreaterThanOrEqual(d / 2);
2505
+ expect(v).toBeLessThanOrEqual(d);
2506
+ }
2507
+ }
2508
+ });
2390
2509
  });
2391
2510
 
2392
2511
  // ============================================================================
@@ -2914,3 +3033,78 @@ describe("subscribeSSE", () => {
2914
3033
  expect(errors[0]).toContain("401");
2915
3034
  });
2916
3035
  });
3036
+
3037
+ describe("baseURL normalization", () => {
3038
+ it("strips a trailing slash so request URLs have no double slash", async () => {
3039
+ mockTokenResponse();
3040
+ const client = new EkoDBClient({
3041
+ baseURL: "http://localhost:8080/",
3042
+ apiKey: "test-api-key",
3043
+ format: SerializationFormat.Json,
3044
+ shouldRetry: false,
3045
+ });
3046
+ await client.init();
3047
+
3048
+ const url = mockFetch.mock.calls[0][0] as string;
3049
+ expect(url).toBe("http://localhost:8080/api/auth/token");
3050
+ expect(url).not.toContain("//api");
3051
+ });
3052
+
3053
+ it("leaves a URL without a trailing slash unchanged", async () => {
3054
+ mockTokenResponse();
3055
+ const client = new EkoDBClient({
3056
+ baseURL: "http://localhost:8080",
3057
+ apiKey: "test-api-key",
3058
+ format: SerializationFormat.Json,
3059
+ shouldRetry: false,
3060
+ });
3061
+ await client.init();
3062
+
3063
+ const url = mockFetch.mock.calls[0][0] as string;
3064
+ expect(url).toBe("http://localhost:8080/api/auth/token");
3065
+ });
3066
+ });
3067
+
3068
+ describe("extractRecordId", () => {
3069
+ it("returns a plain string id", () => {
3070
+ expect(extractRecordId({ id: "abc" })).toBe("abc");
3071
+ });
3072
+
3073
+ it("unwraps a genuine typed wrapper id", () => {
3074
+ expect(extractRecordId({ id: { type: "String", value: "abc" } })).toBe(
3075
+ "abc",
3076
+ );
3077
+ });
3078
+
3079
+ it("stringifies a wrapped numeric id", () => {
3080
+ expect(extractRecordId({ id: { type: "Integer", value: 123 } })).toBe(
3081
+ "123",
3082
+ );
3083
+ });
3084
+
3085
+ it("does not treat a user object with a value key (no type) as the id", () => {
3086
+ // Regression for #134: { value: 1, currency: "USD" } is a user object,
3087
+ // not a typed wrapper, so it must not be unwrapped into the id.
3088
+ expect(
3089
+ extractRecordId({ id: { value: 1, currency: "USD" } }),
3090
+ ).toBeUndefined();
3091
+ });
3092
+
3093
+ it("prefers an alias candidate over id", () => {
3094
+ expect(extractRecordId({ users_id: "u1", id: "x" }, ["users_id"])).toBe(
3095
+ "u1",
3096
+ );
3097
+ });
3098
+
3099
+ it("ignores a non-wrapper alias object and falls back to id", () => {
3100
+ expect(
3101
+ extractRecordId({ users_id: { value: 7, label: "lvl" }, id: "real" }, [
3102
+ "users_id",
3103
+ ]),
3104
+ ).toBe("real");
3105
+ });
3106
+
3107
+ it("falls back to _id", () => {
3108
+ expect(extractRecordId({ _id: "underscore" })).toBe("underscore");
3109
+ });
3110
+ });