@ekodb/ekodb-client 0.19.0 → 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 +108 -5
- package/dist/client.js +401 -64
- package/dist/client.test.js +117 -0
- package/dist/functions.test.d.ts +1 -2
- package/dist/functions.test.js +1 -2
- 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 +1 -1
- package/src/client.test.ts +150 -1
- package/src/client.ts +478 -66
- package/src/functions.test.ts +1 -2
- 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
|
@@ -1816,6 +1816,69 @@ function mockErrorResponse(status, message) {
|
|
|
1816
1816
|
(0, vitest_1.expect)(dataCall[1]?.method).toBe("POST");
|
|
1817
1817
|
(0, vitest_1.expect)(dataCall[1]?.headers?.Accept).toBe("text/event-stream");
|
|
1818
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
|
+
});
|
|
1819
1882
|
});
|
|
1820
1883
|
// ============================================================================
|
|
1821
1884
|
// Schedule CRUD Tests
|
|
@@ -2259,3 +2322,57 @@ function mockErrorResponse(status, message) {
|
|
|
2259
2322
|
(0, vitest_1.expect)(errors[0]).toContain("401");
|
|
2260
2323
|
});
|
|
2261
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/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
package/src/client.test.ts
CHANGED
|
@@ -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();
|
|
@@ -2432,6 +2432,80 @@ describe("EkoDBClient chatMessageStream", () => {
|
|
|
2432
2432
|
expect(dataCall[1]?.method).toBe("POST");
|
|
2433
2433
|
expect(dataCall[1]?.headers?.Accept).toBe("text/event-stream");
|
|
2434
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
|
+
});
|
|
2435
2509
|
});
|
|
2436
2510
|
|
|
2437
2511
|
// ============================================================================
|
|
@@ -2959,3 +3033,78 @@ describe("subscribeSSE", () => {
|
|
|
2959
3033
|
expect(errors[0]).toContain("401");
|
|
2960
3034
|
});
|
|
2961
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
|
+
});
|