@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.
- package/README.md +0 -1
- package/dist/client.d.ts +135 -5
- package/dist/client.js +418 -64
- package/dist/client.test.js +152 -0
- package/dist/functions.test.d.ts +1 -2
- package/dist/functions.test.js +1 -2
- package/dist/index.d.ts +1 -1
- package/dist/query-builder.d.ts +0 -4
- package/dist/query-builder.js +2 -14
- package/dist/query-builder.test.js +0 -5
- package/dist/utils.js +7 -1
- package/dist/utils.test.js +4 -0
- package/dist/websocket.test.js +180 -0
- package/package.json +2 -2
- package/src/client.test.ts +195 -1
- package/src/client.ts +525 -66
- package/src/functions.test.ts +1 -2
- package/src/index.ts +2 -0
- package/src/query-builder.test.ts +0 -7
- package/src/query-builder.ts +2 -14
- package/src/utils.test.ts +5 -0
- package/src/utils.ts +9 -1
- package/src/websocket.test.ts +273 -0
package/dist/client.test.js
CHANGED
|
@@ -489,6 +489,41 @@ function mockErrorResponse(status, message) {
|
|
|
489
489
|
mockJsonResponse({ status: "deleted" });
|
|
490
490
|
await (0, vitest_1.expect)(client.deleteChatSession("chat_123")).resolves.not.toThrow();
|
|
491
491
|
});
|
|
492
|
+
(0, vitest_1.it)("compacts chat history", async () => {
|
|
493
|
+
const client = createTestClient();
|
|
494
|
+
mockTokenResponse();
|
|
495
|
+
mockJsonResponse({
|
|
496
|
+
folded: 12,
|
|
497
|
+
kept_recent: 8,
|
|
498
|
+
summary_chars: 1024,
|
|
499
|
+
summary_message_id: "msg_summary_001",
|
|
500
|
+
already_compact: false,
|
|
501
|
+
});
|
|
502
|
+
const result = await client.compactChat("chat_123", 8);
|
|
503
|
+
(0, vitest_1.expect)(result).toHaveProperty("folded", 12);
|
|
504
|
+
(0, vitest_1.expect)(result).toHaveProperty("kept_recent", 8);
|
|
505
|
+
(0, vitest_1.expect)(result).toHaveProperty("summary_message_id", "msg_summary_001");
|
|
506
|
+
(0, vitest_1.expect)(result).toHaveProperty("already_compact", false);
|
|
507
|
+
const [, init] = mockFetch.mock.calls[1];
|
|
508
|
+
(0, vitest_1.expect)(init.method).toBe("POST");
|
|
509
|
+
(0, vitest_1.expect)(JSON.parse(init.body)).toEqual({ keep_recent: 8 });
|
|
510
|
+
});
|
|
511
|
+
(0, vitest_1.it)("compacts chat history without keepRecent", async () => {
|
|
512
|
+
const client = createTestClient();
|
|
513
|
+
mockTokenResponse();
|
|
514
|
+
mockJsonResponse({
|
|
515
|
+
folded: 0,
|
|
516
|
+
kept_recent: 0,
|
|
517
|
+
summary_chars: 0,
|
|
518
|
+
summary_message_id: null,
|
|
519
|
+
already_compact: true,
|
|
520
|
+
});
|
|
521
|
+
const result = await client.compactChat("chat_123");
|
|
522
|
+
(0, vitest_1.expect)(result.already_compact).toBe(true);
|
|
523
|
+
(0, vitest_1.expect)(result.summary_message_id).toBeNull();
|
|
524
|
+
const [, init] = mockFetch.mock.calls[1];
|
|
525
|
+
(0, vitest_1.expect)(JSON.parse(init.body)).toEqual({});
|
|
526
|
+
});
|
|
492
527
|
});
|
|
493
528
|
// ============================================================================
|
|
494
529
|
// Error Handling Tests
|
|
@@ -1781,6 +1816,69 @@ function mockErrorResponse(status, message) {
|
|
|
1781
1816
|
(0, vitest_1.expect)(dataCall[1]?.method).toBe("POST");
|
|
1782
1817
|
(0, vitest_1.expect)(dataCall[1]?.headers?.Accept).toBe("text/event-stream");
|
|
1783
1818
|
});
|
|
1819
|
+
(0, vitest_1.it)("sends a resolved Bearer token, not a Promise (regression #124)", async () => {
|
|
1820
|
+
const client = createTestClient();
|
|
1821
|
+
mockTokenResponse();
|
|
1822
|
+
mockFetch.mockResolvedValueOnce({
|
|
1823
|
+
ok: true,
|
|
1824
|
+
status: 200,
|
|
1825
|
+
text: async () => 'data: {"token":"ok"}\n',
|
|
1826
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
1827
|
+
});
|
|
1828
|
+
client.chatMessageStream("chat_789", { message: "Test" });
|
|
1829
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1830
|
+
const calls = global.fetch.mock.calls;
|
|
1831
|
+
const auth = calls[1][1]?.headers
|
|
1832
|
+
?.Authorization;
|
|
1833
|
+
(0, vitest_1.expect)(auth).toBe("Bearer test-jwt-token");
|
|
1834
|
+
(0, vitest_1.expect)(auth).not.toContain("[object Promise]");
|
|
1835
|
+
});
|
|
1836
|
+
(0, vitest_1.it)("streams SSE events incrementally from response.body, reassembling split lines (regression #125)", async () => {
|
|
1837
|
+
const client = createTestClient();
|
|
1838
|
+
mockTokenResponse();
|
|
1839
|
+
// A data line is deliberately split across chunk boundaries to exercise the
|
|
1840
|
+
// incremental buffer (the old code buffered the whole body via text()).
|
|
1841
|
+
const enc = new TextEncoder();
|
|
1842
|
+
const chunks = [
|
|
1843
|
+
'data: {"token":"He"}\nda',
|
|
1844
|
+
'ta: {"token":"llo"}\n',
|
|
1845
|
+
'data: {"content":"Hello","message_id":"m1","execution_time_ms":1}\n',
|
|
1846
|
+
];
|
|
1847
|
+
const body = new ReadableStream({
|
|
1848
|
+
start(controller) {
|
|
1849
|
+
for (const c of chunks)
|
|
1850
|
+
controller.enqueue(enc.encode(c));
|
|
1851
|
+
controller.close();
|
|
1852
|
+
},
|
|
1853
|
+
});
|
|
1854
|
+
mockFetch.mockResolvedValueOnce({
|
|
1855
|
+
ok: true,
|
|
1856
|
+
status: 200,
|
|
1857
|
+
body,
|
|
1858
|
+
text: async () => chunks.join(""),
|
|
1859
|
+
headers: new Headers({ "content-type": "text/event-stream" }),
|
|
1860
|
+
});
|
|
1861
|
+
const events = [];
|
|
1862
|
+
const stream = client.chatMessageStream("chat_s", { message: "Hi" });
|
|
1863
|
+
stream.on("event", (evt) => events.push(evt));
|
|
1864
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1865
|
+
(0, vitest_1.expect)(events.filter((e) => e.type === "chunk").map((e) => e.content)).toEqual(["He", "llo"]);
|
|
1866
|
+
(0, vitest_1.expect)(events[events.length - 1].type).toBe("end");
|
|
1867
|
+
(0, vitest_1.expect)(events[events.length - 1].messageId).toBe("m1");
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
(0, vitest_1.describe)("EkoDBClient retry backoff", () => {
|
|
1871
|
+
(0, vitest_1.it)("backoffSeconds grows, caps at 5s, and jitters within [d/2, d] (#126)", () => {
|
|
1872
|
+
const client = createTestClient();
|
|
1873
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1874
|
+
const d = Math.min(0.2 * 2 ** attempt, 5);
|
|
1875
|
+
for (let i = 0; i < 50; i++) {
|
|
1876
|
+
const v = client.backoffSeconds(attempt);
|
|
1877
|
+
(0, vitest_1.expect)(v).toBeGreaterThanOrEqual(d / 2);
|
|
1878
|
+
(0, vitest_1.expect)(v).toBeLessThanOrEqual(d);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1784
1882
|
});
|
|
1785
1883
|
// ============================================================================
|
|
1786
1884
|
// Schedule CRUD Tests
|
|
@@ -2224,3 +2322,57 @@ function mockErrorResponse(status, message) {
|
|
|
2224
2322
|
(0, vitest_1.expect)(errors[0]).toContain("401");
|
|
2225
2323
|
});
|
|
2226
2324
|
});
|
|
2325
|
+
(0, vitest_1.describe)("baseURL normalization", () => {
|
|
2326
|
+
(0, vitest_1.it)("strips a trailing slash so request URLs have no double slash", async () => {
|
|
2327
|
+
mockTokenResponse();
|
|
2328
|
+
const client = new client_1.EkoDBClient({
|
|
2329
|
+
baseURL: "http://localhost:8080/",
|
|
2330
|
+
apiKey: "test-api-key",
|
|
2331
|
+
format: client_1.SerializationFormat.Json,
|
|
2332
|
+
shouldRetry: false,
|
|
2333
|
+
});
|
|
2334
|
+
await client.init();
|
|
2335
|
+
const url = mockFetch.mock.calls[0][0];
|
|
2336
|
+
(0, vitest_1.expect)(url).toBe("http://localhost:8080/api/auth/token");
|
|
2337
|
+
(0, vitest_1.expect)(url).not.toContain("//api");
|
|
2338
|
+
});
|
|
2339
|
+
(0, vitest_1.it)("leaves a URL without a trailing slash unchanged", async () => {
|
|
2340
|
+
mockTokenResponse();
|
|
2341
|
+
const client = new client_1.EkoDBClient({
|
|
2342
|
+
baseURL: "http://localhost:8080",
|
|
2343
|
+
apiKey: "test-api-key",
|
|
2344
|
+
format: client_1.SerializationFormat.Json,
|
|
2345
|
+
shouldRetry: false,
|
|
2346
|
+
});
|
|
2347
|
+
await client.init();
|
|
2348
|
+
const url = mockFetch.mock.calls[0][0];
|
|
2349
|
+
(0, vitest_1.expect)(url).toBe("http://localhost:8080/api/auth/token");
|
|
2350
|
+
});
|
|
2351
|
+
});
|
|
2352
|
+
(0, vitest_1.describe)("extractRecordId", () => {
|
|
2353
|
+
(0, vitest_1.it)("returns a plain string id", () => {
|
|
2354
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ id: "abc" })).toBe("abc");
|
|
2355
|
+
});
|
|
2356
|
+
(0, vitest_1.it)("unwraps a genuine typed wrapper id", () => {
|
|
2357
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ id: { type: "String", value: "abc" } })).toBe("abc");
|
|
2358
|
+
});
|
|
2359
|
+
(0, vitest_1.it)("stringifies a wrapped numeric id", () => {
|
|
2360
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ id: { type: "Integer", value: 123 } })).toBe("123");
|
|
2361
|
+
});
|
|
2362
|
+
(0, vitest_1.it)("does not treat a user object with a value key (no type) as the id", () => {
|
|
2363
|
+
// Regression for #134: { value: 1, currency: "USD" } is a user object,
|
|
2364
|
+
// not a typed wrapper, so it must not be unwrapped into the id.
|
|
2365
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ id: { value: 1, currency: "USD" } })).toBeUndefined();
|
|
2366
|
+
});
|
|
2367
|
+
(0, vitest_1.it)("prefers an alias candidate over id", () => {
|
|
2368
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ users_id: "u1", id: "x" }, ["users_id"])).toBe("u1");
|
|
2369
|
+
});
|
|
2370
|
+
(0, vitest_1.it)("ignores a non-wrapper alias object and falls back to id", () => {
|
|
2371
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ users_id: { value: 7, label: "lvl" }, id: "real" }, [
|
|
2372
|
+
"users_id",
|
|
2373
|
+
])).toBe("real");
|
|
2374
|
+
});
|
|
2375
|
+
(0, vitest_1.it)("falls back to _id", () => {
|
|
2376
|
+
(0, vitest_1.expect)((0, client_1.extractRecordId)({ _id: "underscore" })).toBe("underscore");
|
|
2377
|
+
});
|
|
2378
|
+
});
|
package/dist/functions.test.d.ts
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These tests cover the pure-data construction helpers and the structural
|
|
5
5
|
* parameter placeholder. They don't hit a running ekoDB — server-side
|
|
6
|
-
* behavior is covered by the
|
|
7
|
-
* `ekodb/ekodb_server/tests/function_parameters_tests.rs`.
|
|
6
|
+
* behavior is covered by the server-side integration tests.
|
|
8
7
|
*/
|
|
9
8
|
export {};
|
package/dist/functions.test.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* These tests cover the pure-data construction helpers and the structural
|
|
6
6
|
* parameter placeholder. They don't hit a running ekoDB — server-side
|
|
7
|
-
* behavior is covered by the
|
|
8
|
-
* `ekodb/ekodb_server/tests/function_parameters_tests.rs`.
|
|
7
|
+
* behavior is covered by the server-side integration tests.
|
|
9
8
|
*/
|
|
10
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
10
|
const vitest_1 = require("vitest");
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export type { Schema, FieldTypeSchema, IndexConfig, CollectionMetadata, } from "
|
|
|
12
12
|
export type { JoinConfig } from "./join";
|
|
13
13
|
export type { UserFunction, ParameterDefinition, FunctionStageConfig, GroupFunctionConfig, SortFieldConfig, FunctionResult, FunctionStats, StageStats, } from "./functions";
|
|
14
14
|
export type { MutationNotification, ChatStreamEvent, ClientToolDefinition, ChatSendOptions, SubscribeOptions, } from "./client";
|
|
15
|
-
export type { Record, Query, BatchOperationResult, ClientConfig, RateLimitInfo, CollectionConfig, ChatRequest, CreateChatSessionRequest, ChatMessageRequest, TokenUsage, ChatResponse, ChatSession, ChatSessionResponse, ListSessionsQuery, ListSessionsResponse, GetMessagesQuery, GetMessagesResponse, UpdateSessionRequest, MergeSessionsRequest, ChatModels, EmbedRequest, EmbedResponse, RawCompletionRequest, RawCompletionResponse, ToolChoice, ToolConfig, } from "./client";
|
|
15
|
+
export type { Record, Query, BatchOperationResult, ClientConfig, RateLimitInfo, CollectionConfig, ChatRequest, CreateChatSessionRequest, ChatMessageRequest, TokenUsage, ChatResponse, ChatSession, ChatSessionResponse, ListSessionsQuery, ListSessionsResponse, GetMessagesQuery, GetMessagesResponse, UpdateSessionRequest, MergeSessionsRequest, ChatModels, CompactChatRequest, CompactChatResponse, EmbedRequest, EmbedResponse, RawCompletionRequest, RawCompletionResponse, ToolChoice, ToolConfig, } from "./client";
|
package/dist/query-builder.d.ts
CHANGED
package/dist/query-builder.js
CHANGED
|
@@ -189,20 +189,8 @@ class QueryBuilder {
|
|
|
189
189
|
});
|
|
190
190
|
return this;
|
|
191
191
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
*/
|
|
195
|
-
regex(field, pattern) {
|
|
196
|
-
this.filters.push({
|
|
197
|
-
type: "Condition",
|
|
198
|
-
content: {
|
|
199
|
-
field,
|
|
200
|
-
operator: "Regex",
|
|
201
|
-
value: pattern,
|
|
202
|
-
},
|
|
203
|
-
});
|
|
204
|
-
return this;
|
|
205
|
-
}
|
|
192
|
+
// Note: regex filtering is pending server-side support. The server has no
|
|
193
|
+
// Regex filter operator; use contains/startsWith/endsWith instead.
|
|
206
194
|
// ========================================================================
|
|
207
195
|
// Logical Operators
|
|
208
196
|
// ========================================================================
|
|
@@ -95,10 +95,6 @@ const query_builder_1 = require("./query-builder");
|
|
|
95
95
|
const query = new query_builder_1.QueryBuilder().endsWith("filename", ".pdf").build();
|
|
96
96
|
(0, vitest_1.expect)(query.filter.content.operator).toBe("EndsWith");
|
|
97
97
|
});
|
|
98
|
-
(0, vitest_1.it)("builds regex filter", () => {
|
|
99
|
-
const query = new query_builder_1.QueryBuilder().regex("phone", "^\\+1").build();
|
|
100
|
-
(0, vitest_1.expect)(query.filter.content.operator).toBe("Regex");
|
|
101
|
-
});
|
|
102
98
|
});
|
|
103
99
|
// ============================================================================
|
|
104
100
|
// Logical Operators Tests
|
|
@@ -368,7 +364,6 @@ const query_builder_1 = require("./query-builder");
|
|
|
368
364
|
(0, vitest_1.expect)(qb.contains("i", "j")).toBe(qb);
|
|
369
365
|
(0, vitest_1.expect)(qb.startsWith("k", "l")).toBe(qb);
|
|
370
366
|
(0, vitest_1.expect)(qb.endsWith("m", "n")).toBe(qb);
|
|
371
|
-
(0, vitest_1.expect)(qb.regex("o", "p")).toBe(qb);
|
|
372
367
|
(0, vitest_1.expect)(qb.sortAsc("q")).toBe(qb);
|
|
373
368
|
(0, vitest_1.expect)(qb.sortDesc("r")).toBe(qb);
|
|
374
369
|
(0, vitest_1.expect)(qb.limit(1)).toBe(qb);
|
package/dist/utils.js
CHANGED
|
@@ -33,7 +33,13 @@ exports.extractRecord = extractRecord;
|
|
|
33
33
|
* ```
|
|
34
34
|
*/
|
|
35
35
|
function getValue(field) {
|
|
36
|
-
|
|
36
|
+
// Only unwrap a genuine typed wrapper — one carrying BOTH a "type"
|
|
37
|
+
// discriminator and a "value". A user object that merely has a "value" key
|
|
38
|
+
// (e.g. { value: 1, currency: "USD" }) must pass through untouched.
|
|
39
|
+
if (field &&
|
|
40
|
+
typeof field === "object" &&
|
|
41
|
+
"type" in field &&
|
|
42
|
+
"value" in field) {
|
|
37
43
|
return field.value;
|
|
38
44
|
}
|
|
39
45
|
return field;
|
package/dist/utils.test.js
CHANGED
|
@@ -29,6 +29,10 @@ const utils_1 = require("./utils");
|
|
|
29
29
|
const field = { type: "Null", value: null };
|
|
30
30
|
(0, vitest_1.expect)((0, utils_1.getValue)(field)).toBeNull();
|
|
31
31
|
});
|
|
32
|
+
(0, vitest_1.it)("passes through a user object that has a value key but no type (regression #134)", () => {
|
|
33
|
+
const field = { value: 1, currency: "USD" };
|
|
34
|
+
(0, vitest_1.expect)((0, utils_1.getValue)(field)).toEqual(field);
|
|
35
|
+
});
|
|
32
36
|
(0, vitest_1.it)("returns plain string as-is", () => {
|
|
33
37
|
(0, vitest_1.expect)((0, utils_1.getValue)("plain string")).toBe("plain string");
|
|
34
38
|
});
|
package/dist/websocket.test.js
CHANGED
|
@@ -91,6 +91,31 @@ function waitForMessage(ws) {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
// --------------------------------------------------------------------------
|
|
94
|
+
// URL normalization
|
|
95
|
+
// --------------------------------------------------------------------------
|
|
96
|
+
(0, vitest_1.describe)("URL normalization", () => {
|
|
97
|
+
function captureRequestPath() {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
wss.once("connection", (_ws, req) => resolve(req.url ?? ""));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
(0, vitest_1.it)("appends /api/ws without a double slash when the base URL has a trailing slash", async () => {
|
|
103
|
+
const pathPromise = captureRequestPath();
|
|
104
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/`, "test-token");
|
|
105
|
+
// Trigger a connect; we only assert the path the server receives.
|
|
106
|
+
client.findAll("users").catch(() => { });
|
|
107
|
+
(0, vitest_1.expect)(await pathPromise).toBe("/api/ws");
|
|
108
|
+
client.close();
|
|
109
|
+
});
|
|
110
|
+
(0, vitest_1.it)("does not duplicate /api/ws when the URL already ends with it plus a trailing slash", async () => {
|
|
111
|
+
const pathPromise = captureRequestPath();
|
|
112
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws/`, "test-token");
|
|
113
|
+
client.findAll("users").catch(() => { });
|
|
114
|
+
(0, vitest_1.expect)(await pathPromise).toBe("/api/ws");
|
|
115
|
+
client.close();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// --------------------------------------------------------------------------
|
|
94
119
|
// subscribe
|
|
95
120
|
// --------------------------------------------------------------------------
|
|
96
121
|
(0, vitest_1.describe)("subscribe", () => {
|
|
@@ -493,4 +518,159 @@ function waitForMessage(ws) {
|
|
|
493
518
|
client.close();
|
|
494
519
|
});
|
|
495
520
|
});
|
|
521
|
+
// --------------------------------------------------------------------------
|
|
522
|
+
// Per-request timeout
|
|
523
|
+
// --------------------------------------------------------------------------
|
|
524
|
+
(0, vitest_1.describe)("per-request timeout", () => {
|
|
525
|
+
(0, vitest_1.it)("rejects a pending request when no response arrives", async () => {
|
|
526
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { requestTimeoutMs: 50, autoReconnect: false });
|
|
527
|
+
const resultPromise = client.findAll("users");
|
|
528
|
+
await new Promise((r) => wss.once("connection", r));
|
|
529
|
+
const ws = getLastConnection();
|
|
530
|
+
await waitForMessage(ws); // receive FindAll but never respond
|
|
531
|
+
await (0, vitest_1.expect)(resultPromise).rejects.toThrow(/timed out after 50ms/);
|
|
532
|
+
client.close();
|
|
533
|
+
});
|
|
534
|
+
(0, vitest_1.it)("does not time out when a response arrives in time", async () => {
|
|
535
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { requestTimeoutMs: 500, autoReconnect: false });
|
|
536
|
+
const resultPromise = client.findAll("users");
|
|
537
|
+
await new Promise((r) => wss.once("connection", r));
|
|
538
|
+
const ws = getLastConnection();
|
|
539
|
+
const msg = await waitForMessage(ws);
|
|
540
|
+
ws.send(JSON.stringify({
|
|
541
|
+
type: "Success",
|
|
542
|
+
payload: { message_id: msg.messageId, data: [{ id: "1" }] },
|
|
543
|
+
}));
|
|
544
|
+
await (0, vitest_1.expect)(resultPromise).resolves.toEqual([{ id: "1" }]);
|
|
545
|
+
client.close();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
// --------------------------------------------------------------------------
|
|
549
|
+
// Reject pending requests on disconnect
|
|
550
|
+
// --------------------------------------------------------------------------
|
|
551
|
+
(0, vitest_1.describe)("disconnect handling", () => {
|
|
552
|
+
(0, vitest_1.it)("rejects in-flight requests when the socket drops", async () => {
|
|
553
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { autoReconnect: false });
|
|
554
|
+
const resultPromise = client.findAll("users");
|
|
555
|
+
await new Promise((r) => wss.once("connection", r));
|
|
556
|
+
const ws = getLastConnection();
|
|
557
|
+
await waitForMessage(ws); // FindAll received, never answered
|
|
558
|
+
// Server drops the connection.
|
|
559
|
+
ws.close();
|
|
560
|
+
await (0, vitest_1.expect)(resultPromise).rejects.toThrow(/connection closed/i);
|
|
561
|
+
client.close();
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
// --------------------------------------------------------------------------
|
|
565
|
+
// Backoff helper
|
|
566
|
+
// --------------------------------------------------------------------------
|
|
567
|
+
(0, vitest_1.describe)("computeBackoff", () => {
|
|
568
|
+
(0, vitest_1.it)("grows exponentially, stays within jittered bounds, and caps", () => {
|
|
569
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { reconnectInitialDelayMs: 200, reconnectMaxDelayMs: 5000 });
|
|
570
|
+
// attempt 0 -> base 200, +/-25% => [150, 250]
|
|
571
|
+
for (let i = 0; i < 20; i++) {
|
|
572
|
+
const d0 = client.computeBackoff(0);
|
|
573
|
+
(0, vitest_1.expect)(d0).toBeGreaterThanOrEqual(150);
|
|
574
|
+
(0, vitest_1.expect)(d0).toBeLessThanOrEqual(250);
|
|
575
|
+
}
|
|
576
|
+
// attempt 3 -> base 1600, +/-25% => [1200, 2000]
|
|
577
|
+
const d3 = client.computeBackoff(3);
|
|
578
|
+
(0, vitest_1.expect)(d3).toBeGreaterThanOrEqual(1200);
|
|
579
|
+
(0, vitest_1.expect)(d3).toBeLessThanOrEqual(2000);
|
|
580
|
+
// attempt 20 -> capped at 5000, +/-25% => [3750, 6250]
|
|
581
|
+
const dBig = client.computeBackoff(20);
|
|
582
|
+
(0, vitest_1.expect)(dBig).toBeGreaterThanOrEqual(3750);
|
|
583
|
+
(0, vitest_1.expect)(dBig).toBeLessThanOrEqual(6250);
|
|
584
|
+
client.close();
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
// --------------------------------------------------------------------------
|
|
588
|
+
// Auto-reconnect + re-subscribe + fresh token
|
|
589
|
+
// --------------------------------------------------------------------------
|
|
590
|
+
(0, vitest_1.describe)("auto-reconnect", () => {
|
|
591
|
+
(0, vitest_1.it)("re-subscribes after a socket drop and re-evaluates the token", async () => {
|
|
592
|
+
// Token provider returns a new token each call so we can assert the
|
|
593
|
+
// reconnect used a freshly-obtained token (not a stale snapshot).
|
|
594
|
+
let tokenCalls = 0;
|
|
595
|
+
const tokenProvider = () => `token-${++tokenCalls}`;
|
|
596
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, tokenProvider, {
|
|
597
|
+
autoReconnect: true,
|
|
598
|
+
reconnectInitialDelayMs: 10,
|
|
599
|
+
reconnectMaxDelayMs: 30,
|
|
600
|
+
});
|
|
601
|
+
// Establish the subscription on the first connection.
|
|
602
|
+
const streamPromise = client.subscribe("orders");
|
|
603
|
+
await new Promise((r) => wss.once("connection", r));
|
|
604
|
+
const ws1 = getLastConnection();
|
|
605
|
+
const sub1 = await waitForMessage(ws1);
|
|
606
|
+
(0, vitest_1.expect)(sub1.type).toBe("Subscribe");
|
|
607
|
+
(0, vitest_1.expect)(sub1.payload.collection).toBe("orders");
|
|
608
|
+
ws1.send(JSON.stringify({
|
|
609
|
+
type: "Success",
|
|
610
|
+
payload: { message_id: sub1.messageId, status: "subscribed" },
|
|
611
|
+
}));
|
|
612
|
+
const stream = await streamPromise;
|
|
613
|
+
(0, vitest_1.expect)(tokenCalls).toBe(1);
|
|
614
|
+
// Arm the next-connection handler BEFORE dropping so we don't miss it.
|
|
615
|
+
const nextConn = new Promise((resolve) => {
|
|
616
|
+
wss.once("connection", (ws) => resolve(ws));
|
|
617
|
+
});
|
|
618
|
+
// Simulate a transient drop: server closes the socket.
|
|
619
|
+
ws1.close();
|
|
620
|
+
// The client should auto-reconnect (new connection) and re-send Subscribe.
|
|
621
|
+
const ws2 = await nextConn;
|
|
622
|
+
const sub2 = await waitForMessage(ws2);
|
|
623
|
+
(0, vitest_1.expect)(sub2.type).toBe("Subscribe");
|
|
624
|
+
(0, vitest_1.expect)(sub2.payload.collection).toBe("orders");
|
|
625
|
+
// Reconnect re-evaluated the token provider.
|
|
626
|
+
(0, vitest_1.expect)(tokenCalls).toBeGreaterThanOrEqual(2);
|
|
627
|
+
// Ack the re-subscribe.
|
|
628
|
+
ws2.send(JSON.stringify({
|
|
629
|
+
type: "Success",
|
|
630
|
+
payload: { message_id: sub2.messageId, status: "subscribed" },
|
|
631
|
+
}));
|
|
632
|
+
// The SAME stream still delivers mutations over the new socket.
|
|
633
|
+
const mutationPromise = new Promise((resolve) => {
|
|
634
|
+
stream.on("mutation", resolve);
|
|
635
|
+
});
|
|
636
|
+
ws2.send(JSON.stringify({
|
|
637
|
+
type: "MutationNotification",
|
|
638
|
+
payload: {
|
|
639
|
+
collection: "orders",
|
|
640
|
+
event: "insert",
|
|
641
|
+
record_ids: ["order-9"],
|
|
642
|
+
timestamp: "2026-06-02T00:00:00Z",
|
|
643
|
+
},
|
|
644
|
+
}));
|
|
645
|
+
const mutation = await mutationPromise;
|
|
646
|
+
(0, vitest_1.expect)(mutation.recordIds).toEqual(["order-9"]);
|
|
647
|
+
(0, vitest_1.expect)(stream.closed).toBe(false);
|
|
648
|
+
client.close();
|
|
649
|
+
});
|
|
650
|
+
(0, vitest_1.it)("does not reconnect after an intentional close()", async () => {
|
|
651
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { autoReconnect: true, reconnectInitialDelayMs: 10 });
|
|
652
|
+
const streamPromise = client.subscribe("widgets");
|
|
653
|
+
await new Promise((r) => wss.once("connection", r));
|
|
654
|
+
const ws = getLastConnection();
|
|
655
|
+
const sub = await waitForMessage(ws);
|
|
656
|
+
ws.send(JSON.stringify({
|
|
657
|
+
type: "Success",
|
|
658
|
+
payload: { message_id: sub.messageId, status: "subscribed" },
|
|
659
|
+
}));
|
|
660
|
+
await streamPromise;
|
|
661
|
+
const connectionsBefore = serverConnections.length;
|
|
662
|
+
// Intentional close: must NOT reconnect.
|
|
663
|
+
client.close();
|
|
664
|
+
// Give any erroneous reconnect time to land.
|
|
665
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
666
|
+
(0, vitest_1.expect)(serverConnections.length).toBe(connectionsBefore);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
(0, vitest_1.describe)("auth token validation", () => {
|
|
670
|
+
(0, vitest_1.it)("rejects connect when the token provider returns null (no Bearer null)", async () => {
|
|
671
|
+
const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, () => null);
|
|
672
|
+
await (0, vitest_1.expect)(client.findAll("users")).rejects.toThrow(/token is unavailable/i);
|
|
673
|
+
client.close();
|
|
674
|
+
});
|
|
675
|
+
});
|
|
496
676
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ekodb/ekodb-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Official TypeScript/JavaScript client for ekoDB",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -27,6 +27,6 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@msgpack/msgpack": "^3.1.3",
|
|
30
|
-
"ws": "^8.
|
|
30
|
+
"ws": "^8.20.1"
|
|
31
31
|
}
|
|
32
32
|
}
|