@ekodb/ekodb-client 0.21.0 → 0.23.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/dist/client.d.ts +27 -2
- package/dist/client.js +69 -8
- package/dist/client.test.js +99 -0
- package/dist/search.d.ts +12 -0
- package/dist/search.js +9 -0
- package/package.json +1 -1
- package/src/client.test.ts +126 -0
- package/src/client.ts +81 -13
- package/src/search.ts +18 -0
package/dist/client.d.ts
CHANGED
|
@@ -508,16 +508,30 @@ export declare class EkoDBClient {
|
|
|
508
508
|
batchInsert(collection: string, records: Record[], options?: BatchInsertOptions): Promise<BatchOperationResult>;
|
|
509
509
|
/**
|
|
510
510
|
* Batch update multiple documents
|
|
511
|
+
* @param collection - Collection name
|
|
512
|
+
* @param updates - Array of updates ({ id, data, bypassRipple? })
|
|
513
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
514
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
515
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
516
|
+
* immediately — mirrors the single-record update.
|
|
511
517
|
*/
|
|
512
518
|
batchUpdate(collection: string, updates: Array<{
|
|
513
519
|
id: string;
|
|
514
520
|
data: Record;
|
|
515
521
|
bypassRipple?: boolean;
|
|
516
|
-
}
|
|
522
|
+
}>, options?: BatchUpdateOptions): Promise<BatchOperationResult>;
|
|
517
523
|
/**
|
|
518
524
|
* Batch delete multiple documents
|
|
525
|
+
* @param collection - Collection name
|
|
526
|
+
* @param ids - Document IDs to delete
|
|
527
|
+
* @param bypassRipple - Optional flag to bypass ripple propagation (legacy
|
|
528
|
+
* positional form, kept for back-compat)
|
|
529
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
530
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
531
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
532
|
+
* immediately — mirrors the single-record delete.
|
|
519
533
|
*/
|
|
520
|
-
batchDelete(collection: string, ids: string[], bypassRipple?: boolean): Promise<BatchOperationResult>;
|
|
534
|
+
batchDelete(collection: string, ids: string[], bypassRipple?: boolean, options?: BatchDeleteOptions): Promise<BatchOperationResult>;
|
|
521
535
|
/**
|
|
522
536
|
* Set a key-value pair with optional TTL
|
|
523
537
|
* @param key - The key to set
|
|
@@ -895,6 +909,17 @@ export declare class EkoDBClient {
|
|
|
895
909
|
* Unblocks ekoDB's tool loop so it can feed the result to the LLM.
|
|
896
910
|
*/
|
|
897
911
|
submitChatToolResult(chatId: string, callId: string, success: boolean, result?: any, error?: string): Promise<void>;
|
|
912
|
+
/**
|
|
913
|
+
* Send a client tool keepalive (liveness ping) for an in-flight SSE chat stream.
|
|
914
|
+
*
|
|
915
|
+
* This is NOT a result: it does not unblock the tool loop or feed anything to the
|
|
916
|
+
* LLM. It simply resets the server's per-tool wait deadline (governed by
|
|
917
|
+
* `client_tool_timeout_secs`, default 60s) so that slow user confirmations or
|
|
918
|
+
* long-running client tools don't get the turn timed out before
|
|
919
|
+
* {@link submitChatToolResult} arrives. Send it periodically while a client tool
|
|
920
|
+
* is still working. See ekoDB#530.
|
|
921
|
+
*/
|
|
922
|
+
submitChatToolKeepalive(chatId: string, callId: string): Promise<void>;
|
|
898
923
|
/**
|
|
899
924
|
* Send a message in an existing chat session via SSE streaming.
|
|
900
925
|
*
|
package/dist/client.js
CHANGED
|
@@ -599,24 +599,61 @@ class EkoDBClient {
|
|
|
599
599
|
}
|
|
600
600
|
/**
|
|
601
601
|
* Batch update multiple documents
|
|
602
|
+
* @param collection - Collection name
|
|
603
|
+
* @param updates - Array of updates ({ id, data, bypassRipple? })
|
|
604
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
605
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
606
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
607
|
+
* immediately — mirrors the single-record update.
|
|
602
608
|
*/
|
|
603
|
-
async batchUpdate(collection, updates) {
|
|
609
|
+
async batchUpdate(collection, updates, options) {
|
|
610
|
+
const params = new URLSearchParams();
|
|
611
|
+
if (options?.bypassRipple !== undefined) {
|
|
612
|
+
params.append("bypass_ripple", String(options.bypassRipple));
|
|
613
|
+
}
|
|
614
|
+
if (options?.transactionId) {
|
|
615
|
+
params.append("transaction_id", options.transactionId);
|
|
616
|
+
}
|
|
604
617
|
const formattedUpdates = updates.map((u) => ({
|
|
605
618
|
id: u.id,
|
|
606
619
|
data: u.data,
|
|
607
620
|
bypass_ripple: u.bypassRipple,
|
|
608
621
|
}));
|
|
609
|
-
|
|
622
|
+
const url = params.toString()
|
|
623
|
+
? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
|
|
624
|
+
: `/api/batch/update/${encodeURIComponent(collection)}`;
|
|
625
|
+
return this.makeRequest("PUT", url, {
|
|
626
|
+
updates: formattedUpdates,
|
|
627
|
+
});
|
|
610
628
|
}
|
|
611
629
|
/**
|
|
612
630
|
* Batch delete multiple documents
|
|
613
|
-
|
|
614
|
-
|
|
631
|
+
* @param collection - Collection name
|
|
632
|
+
* @param ids - Document IDs to delete
|
|
633
|
+
* @param bypassRipple - Optional flag to bypass ripple propagation (legacy
|
|
634
|
+
* positional form, kept for back-compat)
|
|
635
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
636
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
637
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
638
|
+
* immediately — mirrors the single-record delete.
|
|
639
|
+
*/
|
|
640
|
+
async batchDelete(collection, ids, bypassRipple, options) {
|
|
641
|
+
// bypass_ripple is sent per-item in the body (existing behavior). The
|
|
642
|
+
// effective value prefers the explicit positional arg, falling back to the
|
|
643
|
+
// options object so callers can use either form.
|
|
644
|
+
const effectiveBypassRipple = bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
|
|
645
|
+
const params = new URLSearchParams();
|
|
646
|
+
if (options?.transactionId) {
|
|
647
|
+
params.append("transaction_id", options.transactionId);
|
|
648
|
+
}
|
|
615
649
|
const deletes = ids.map((id) => ({
|
|
616
650
|
id: id,
|
|
617
|
-
bypass_ripple:
|
|
651
|
+
bypass_ripple: effectiveBypassRipple,
|
|
618
652
|
}));
|
|
619
|
-
|
|
653
|
+
const url = params.toString()
|
|
654
|
+
? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
|
|
655
|
+
: `/api/batch/delete/${encodeURIComponent(collection)}`;
|
|
656
|
+
return this.makeRequest("DELETE", url, { deletes });
|
|
620
657
|
}
|
|
621
658
|
/**
|
|
622
659
|
* Set a key-value pair with optional TTL
|
|
@@ -1249,6 +1286,22 @@ class EkoDBClient {
|
|
|
1249
1286
|
...(error !== undefined && { error }),
|
|
1250
1287
|
}, 0, true);
|
|
1251
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Send a client tool keepalive (liveness ping) for an in-flight SSE chat stream.
|
|
1291
|
+
*
|
|
1292
|
+
* This is NOT a result: it does not unblock the tool loop or feed anything to the
|
|
1293
|
+
* LLM. It simply resets the server's per-tool wait deadline (governed by
|
|
1294
|
+
* `client_tool_timeout_secs`, default 60s) so that slow user confirmations or
|
|
1295
|
+
* long-running client tools don't get the turn timed out before
|
|
1296
|
+
* {@link submitChatToolResult} arrives. Send it periodically while a client tool
|
|
1297
|
+
* is still working. See ekoDB#530.
|
|
1298
|
+
*/
|
|
1299
|
+
async submitChatToolKeepalive(chatId, callId) {
|
|
1300
|
+
await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
|
|
1301
|
+
call_id: callId,
|
|
1302
|
+
keepalive: true,
|
|
1303
|
+
}, 0, true);
|
|
1304
|
+
}
|
|
1252
1305
|
/**
|
|
1253
1306
|
* Send a message in an existing chat session via SSE streaming.
|
|
1254
1307
|
*
|
|
@@ -1517,7 +1570,11 @@ class EkoDBClient {
|
|
|
1517
1570
|
* List all functions, optionally filtered by tags
|
|
1518
1571
|
*/
|
|
1519
1572
|
async listFunctions(tags) {
|
|
1520
|
-
|
|
1573
|
+
// URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
|
|
1574
|
+
// containing query-reserved characters can't smuggle extra params.
|
|
1575
|
+
const params = tags
|
|
1576
|
+
? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
|
|
1577
|
+
: "";
|
|
1521
1578
|
return this.makeRequest("GET", `/api/functions${params}`);
|
|
1522
1579
|
}
|
|
1523
1580
|
/**
|
|
@@ -1564,7 +1621,11 @@ class EkoDBClient {
|
|
|
1564
1621
|
* @returns Array of user functions
|
|
1565
1622
|
*/
|
|
1566
1623
|
async listUserFunctions(tags) {
|
|
1567
|
-
|
|
1624
|
+
// URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
|
|
1625
|
+
// containing query-reserved characters can't smuggle extra params.
|
|
1626
|
+
const params = tags
|
|
1627
|
+
? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
|
|
1628
|
+
: "";
|
|
1568
1629
|
return this.makeRequest("GET", `/api/functions${params}`, undefined, 0, true);
|
|
1569
1630
|
}
|
|
1570
1631
|
/**
|
package/dist/client.test.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
const vitest_1 = require("vitest");
|
|
10
10
|
const client_1 = require("./client");
|
|
11
|
+
const search_1 = require("./search");
|
|
11
12
|
// Mock fetch globally
|
|
12
13
|
const mockFetch = vitest_1.vi.fn();
|
|
13
14
|
global.fetch = mockFetch;
|
|
@@ -226,6 +227,56 @@ function mockErrorResponse(status, message) {
|
|
|
226
227
|
const result = await client.batchDelete("users", ["id_1", "id_2"]);
|
|
227
228
|
(0, vitest_1.expect)(result.successful).toHaveLength(2);
|
|
228
229
|
});
|
|
230
|
+
(0, vitest_1.it)("batchInsert sends transaction_id as a query param", async () => {
|
|
231
|
+
const client = createTestClient();
|
|
232
|
+
mockTokenResponse();
|
|
233
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
234
|
+
await client.batchInsert("users", [{ name: "A" }], {
|
|
235
|
+
transactionId: "tx_123",
|
|
236
|
+
});
|
|
237
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
238
|
+
(0, vitest_1.expect)(init.method).toBe("POST");
|
|
239
|
+
const parsed = new URL(url);
|
|
240
|
+
(0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/insert/users");
|
|
241
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
242
|
+
});
|
|
243
|
+
(0, vitest_1.it)("batchUpdate sends transaction_id as a query param", async () => {
|
|
244
|
+
const client = createTestClient();
|
|
245
|
+
mockTokenResponse();
|
|
246
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
247
|
+
await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
|
|
248
|
+
transactionId: "tx_123",
|
|
249
|
+
});
|
|
250
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
251
|
+
(0, vitest_1.expect)(init.method).toBe("PUT");
|
|
252
|
+
const parsed = new URL(url);
|
|
253
|
+
(0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/update/users");
|
|
254
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
255
|
+
});
|
|
256
|
+
(0, vitest_1.it)("batchDelete sends transaction_id as a query param", async () => {
|
|
257
|
+
const client = createTestClient();
|
|
258
|
+
mockTokenResponse();
|
|
259
|
+
mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
|
|
260
|
+
await client.batchDelete("users", ["id_1", "id_2"], undefined, {
|
|
261
|
+
transactionId: "tx_123",
|
|
262
|
+
});
|
|
263
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
264
|
+
(0, vitest_1.expect)(init.method).toBe("DELETE");
|
|
265
|
+
const parsed = new URL(url);
|
|
266
|
+
(0, vitest_1.expect)(parsed.pathname).toBe("/api/batch/delete/users");
|
|
267
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
268
|
+
});
|
|
269
|
+
(0, vitest_1.it)("batch ops without transaction_id send no such query param (additive)", async () => {
|
|
270
|
+
const client = createTestClient();
|
|
271
|
+
mockTokenResponse();
|
|
272
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
273
|
+
// Legacy call sites: no options object at all.
|
|
274
|
+
await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
|
|
275
|
+
const [url] = mockFetch.mock.calls[1];
|
|
276
|
+
const parsed = new URL(url);
|
|
277
|
+
(0, vitest_1.expect)(parsed.searchParams.get("transaction_id")).toBeNull();
|
|
278
|
+
(0, vitest_1.expect)(parsed.search).toBe("");
|
|
279
|
+
});
|
|
229
280
|
});
|
|
230
281
|
// ============================================================================
|
|
231
282
|
// KV Store Tests
|
|
@@ -345,6 +396,22 @@ function mockErrorResponse(status, message) {
|
|
|
345
396
|
(0, vitest_1.expect)(result.total).toBe(2);
|
|
346
397
|
});
|
|
347
398
|
// Note: textSearch and hybridSearch require specific mock setup - covered by integration tests
|
|
399
|
+
(0, vitest_1.it)("builds a vector search query with a metadata filter", () => {
|
|
400
|
+
const filter = {
|
|
401
|
+
type: "Condition",
|
|
402
|
+
content: { field: "category", operator: "Eq", value: "ml" },
|
|
403
|
+
};
|
|
404
|
+
const query = new search_1.SearchQueryBuilder("test")
|
|
405
|
+
.vector([0.1, 0.2, 0.3])
|
|
406
|
+
.filters(filter)
|
|
407
|
+
.build();
|
|
408
|
+
(0, vitest_1.expect)(query.filters).toEqual(filter);
|
|
409
|
+
(0, vitest_1.expect)(query.vector).toEqual([0.1, 0.2, 0.3]);
|
|
410
|
+
});
|
|
411
|
+
(0, vitest_1.it)("omits filters when not set", () => {
|
|
412
|
+
const query = new search_1.SearchQueryBuilder("test").build();
|
|
413
|
+
(0, vitest_1.expect)(query.filters).toBeUndefined();
|
|
414
|
+
});
|
|
348
415
|
});
|
|
349
416
|
// ============================================================================
|
|
350
417
|
// KV Batch Operations Tests
|
|
@@ -2318,6 +2385,25 @@ function mockErrorResponse(status, message) {
|
|
|
2318
2385
|
});
|
|
2319
2386
|
});
|
|
2320
2387
|
// ============================================================================
|
|
2388
|
+
// submitChatToolKeepalive Tests
|
|
2389
|
+
// ============================================================================
|
|
2390
|
+
(0, vitest_1.describe)("submitChatToolKeepalive", () => {
|
|
2391
|
+
(0, vitest_1.it)("sends keepalive to correct endpoint", async () => {
|
|
2392
|
+
const client = createTestClient();
|
|
2393
|
+
mockTokenResponse();
|
|
2394
|
+
mockJsonResponse({});
|
|
2395
|
+
await client.submitChatToolKeepalive("chat-123", "call-456");
|
|
2396
|
+
(0, vitest_1.expect)(mockFetch).toHaveBeenCalledTimes(2);
|
|
2397
|
+
const call = mockFetch.mock.calls[1];
|
|
2398
|
+
(0, vitest_1.expect)(call[0]).toContain("/api/chat/chat-123/tool-result");
|
|
2399
|
+
const body = JSON.parse(call[1].body);
|
|
2400
|
+
(0, vitest_1.expect)(body.call_id).toBe("call-456");
|
|
2401
|
+
(0, vitest_1.expect)(body.keepalive).toBe(true);
|
|
2402
|
+
(0, vitest_1.expect)(body.success).toBeUndefined();
|
|
2403
|
+
(0, vitest_1.expect)(body.result).toBeUndefined();
|
|
2404
|
+
});
|
|
2405
|
+
});
|
|
2406
|
+
// ============================================================================
|
|
2321
2407
|
// subscribeSSE Tests
|
|
2322
2408
|
// ============================================================================
|
|
2323
2409
|
(0, vitest_1.describe)("subscribeSSE", () => {
|
|
@@ -2467,6 +2553,19 @@ function mockErrorResponse(status, message) {
|
|
|
2467
2553
|
// URLSearchParams, so they are out of scope here.
|
|
2468
2554
|
// ============================================================================
|
|
2469
2555
|
(0, vitest_1.describe)("EkoDBClient URL path segment encoding", () => {
|
|
2556
|
+
(0, vitest_1.it)("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
|
|
2557
|
+
// A tag with query-reserved characters must be percent-encoded, not
|
|
2558
|
+
// concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
|
|
2559
|
+
// splits into tags="a" plus a smuggled `injected=1` query param.
|
|
2560
|
+
const client = createTestClient();
|
|
2561
|
+
mockTokenResponse();
|
|
2562
|
+
mockJsonResponse([]);
|
|
2563
|
+
await client.listUserFunctions(["a&injected=1", "b"]);
|
|
2564
|
+
const [url] = mockFetch.mock.calls[1];
|
|
2565
|
+
const parsed = new URL(url);
|
|
2566
|
+
(0, vitest_1.expect)(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
|
|
2567
|
+
(0, vitest_1.expect)(parsed.searchParams.has("injected")).toBe(false);
|
|
2568
|
+
});
|
|
2470
2569
|
(0, vitest_1.it)("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
|
|
2471
2570
|
const client = createTestClient();
|
|
2472
2571
|
mockTokenResponse();
|
package/dist/search.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface SearchQuery {
|
|
|
52
52
|
select_fields?: string[];
|
|
53
53
|
/** Exclude these fields from results */
|
|
54
54
|
exclude_fields?: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Metadata pre-filter for text/vector/hybrid search. A canonical QueryExpression
|
|
57
|
+
* (the same shape produced by `QueryBuilder.build().filter`); only records
|
|
58
|
+
* matching the filter are considered as candidates before ranking.
|
|
59
|
+
*/
|
|
60
|
+
filters?: any;
|
|
55
61
|
}
|
|
56
62
|
/**
|
|
57
63
|
* Search result with score and matched fields
|
|
@@ -177,6 +183,12 @@ export declare class SearchQueryBuilder {
|
|
|
177
183
|
* Exclude specific fields from results
|
|
178
184
|
*/
|
|
179
185
|
excludeFields(fields: string[]): this;
|
|
186
|
+
/**
|
|
187
|
+
* Set a metadata pre-filter for text/vector/hybrid search. Accepts a canonical
|
|
188
|
+
* QueryExpression (the same shape produced by `QueryBuilder.build().filter`);
|
|
189
|
+
* only records matching the filter are considered before ranking.
|
|
190
|
+
*/
|
|
191
|
+
filters(filter: any): this;
|
|
180
192
|
/**
|
|
181
193
|
* Build the final SearchQuery object
|
|
182
194
|
*/
|
package/dist/search.js
CHANGED
|
@@ -176,6 +176,15 @@ class SearchQueryBuilder {
|
|
|
176
176
|
this.query.exclude_fields = fields;
|
|
177
177
|
return this;
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Set a metadata pre-filter for text/vector/hybrid search. Accepts a canonical
|
|
181
|
+
* QueryExpression (the same shape produced by `QueryBuilder.build().filter`);
|
|
182
|
+
* only records matching the filter are considered before ranking.
|
|
183
|
+
*/
|
|
184
|
+
filters(filter) {
|
|
185
|
+
this.query.filters = filter;
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
179
188
|
/**
|
|
180
189
|
* Build the final SearchQuery object
|
|
181
190
|
*/
|
package/package.json
CHANGED
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();
|
|
@@ -312,6 +313,72 @@ describe("EkoDBClient batch operations", () => {
|
|
|
312
313
|
|
|
313
314
|
expect(result.successful).toHaveLength(2);
|
|
314
315
|
});
|
|
316
|
+
|
|
317
|
+
it("batchInsert sends transaction_id as a query param", async () => {
|
|
318
|
+
const client = createTestClient();
|
|
319
|
+
|
|
320
|
+
mockTokenResponse();
|
|
321
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
322
|
+
|
|
323
|
+
await client.batchInsert("users", [{ name: "A" }], {
|
|
324
|
+
transactionId: "tx_123",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
328
|
+
expect(init.method).toBe("POST");
|
|
329
|
+
const parsed = new URL(url as string);
|
|
330
|
+
expect(parsed.pathname).toBe("/api/batch/insert/users");
|
|
331
|
+
expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("batchUpdate sends transaction_id as a query param", async () => {
|
|
335
|
+
const client = createTestClient();
|
|
336
|
+
|
|
337
|
+
mockTokenResponse();
|
|
338
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
339
|
+
|
|
340
|
+
await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }], {
|
|
341
|
+
transactionId: "tx_123",
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
345
|
+
expect(init.method).toBe("PUT");
|
|
346
|
+
const parsed = new URL(url as string);
|
|
347
|
+
expect(parsed.pathname).toBe("/api/batch/update/users");
|
|
348
|
+
expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("batchDelete sends transaction_id as a query param", async () => {
|
|
352
|
+
const client = createTestClient();
|
|
353
|
+
|
|
354
|
+
mockTokenResponse();
|
|
355
|
+
mockJsonResponse({ successful: ["id_1", "id_2"], failed: [] });
|
|
356
|
+
|
|
357
|
+
await client.batchDelete("users", ["id_1", "id_2"], undefined, {
|
|
358
|
+
transactionId: "tx_123",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const [url, init] = mockFetch.mock.calls[1];
|
|
362
|
+
expect(init.method).toBe("DELETE");
|
|
363
|
+
const parsed = new URL(url as string);
|
|
364
|
+
expect(parsed.pathname).toBe("/api/batch/delete/users");
|
|
365
|
+
expect(parsed.searchParams.get("transaction_id")).toBe("tx_123");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("batch ops without transaction_id send no such query param (additive)", async () => {
|
|
369
|
+
const client = createTestClient();
|
|
370
|
+
|
|
371
|
+
mockTokenResponse();
|
|
372
|
+
mockJsonResponse({ successful: ["id_1"], failed: [] });
|
|
373
|
+
|
|
374
|
+
// Legacy call sites: no options object at all.
|
|
375
|
+
await client.batchUpdate("users", [{ id: "id_1", data: { name: "B" } }]);
|
|
376
|
+
|
|
377
|
+
const [url] = mockFetch.mock.calls[1];
|
|
378
|
+
const parsed = new URL(url as string);
|
|
379
|
+
expect(parsed.searchParams.get("transaction_id")).toBeNull();
|
|
380
|
+
expect(parsed.search).toBe("");
|
|
381
|
+
});
|
|
315
382
|
});
|
|
316
383
|
|
|
317
384
|
// ============================================================================
|
|
@@ -489,6 +556,25 @@ describe("EkoDBClient search", () => {
|
|
|
489
556
|
});
|
|
490
557
|
|
|
491
558
|
// Note: textSearch and hybridSearch require specific mock setup - covered by integration tests
|
|
559
|
+
|
|
560
|
+
it("builds a vector search query with a metadata filter", () => {
|
|
561
|
+
const filter = {
|
|
562
|
+
type: "Condition",
|
|
563
|
+
content: { field: "category", operator: "Eq", value: "ml" },
|
|
564
|
+
};
|
|
565
|
+
const query = new SearchQueryBuilder("test")
|
|
566
|
+
.vector([0.1, 0.2, 0.3])
|
|
567
|
+
.filters(filter)
|
|
568
|
+
.build();
|
|
569
|
+
|
|
570
|
+
expect(query.filters).toEqual(filter);
|
|
571
|
+
expect(query.vector).toEqual([0.1, 0.2, 0.3]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("omits filters when not set", () => {
|
|
575
|
+
const query = new SearchQueryBuilder("test").build();
|
|
576
|
+
expect(query.filters).toBeUndefined();
|
|
577
|
+
});
|
|
492
578
|
});
|
|
493
579
|
|
|
494
580
|
// ============================================================================
|
|
@@ -3049,6 +3135,29 @@ describe("submitChatToolResult", () => {
|
|
|
3049
3135
|
});
|
|
3050
3136
|
});
|
|
3051
3137
|
|
|
3138
|
+
// ============================================================================
|
|
3139
|
+
// submitChatToolKeepalive Tests
|
|
3140
|
+
// ============================================================================
|
|
3141
|
+
|
|
3142
|
+
describe("submitChatToolKeepalive", () => {
|
|
3143
|
+
it("sends keepalive to correct endpoint", async () => {
|
|
3144
|
+
const client = createTestClient();
|
|
3145
|
+
mockTokenResponse();
|
|
3146
|
+
mockJsonResponse({});
|
|
3147
|
+
|
|
3148
|
+
await client.submitChatToolKeepalive("chat-123", "call-456");
|
|
3149
|
+
|
|
3150
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
3151
|
+
const call = mockFetch.mock.calls[1];
|
|
3152
|
+
expect(call[0]).toContain("/api/chat/chat-123/tool-result");
|
|
3153
|
+
const body = JSON.parse(call[1].body);
|
|
3154
|
+
expect(body.call_id).toBe("call-456");
|
|
3155
|
+
expect(body.keepalive).toBe(true);
|
|
3156
|
+
expect(body.success).toBeUndefined();
|
|
3157
|
+
expect(body.result).toBeUndefined();
|
|
3158
|
+
});
|
|
3159
|
+
});
|
|
3160
|
+
|
|
3052
3161
|
// ============================================================================
|
|
3053
3162
|
// subscribeSSE Tests
|
|
3054
3163
|
// ============================================================================
|
|
@@ -3238,6 +3347,23 @@ describe("extractRecordId", () => {
|
|
|
3238
3347
|
// ============================================================================
|
|
3239
3348
|
|
|
3240
3349
|
describe("EkoDBClient URL path segment encoding", () => {
|
|
3350
|
+
it("listUserFunctions percent-encodes reserved chars in the tags query param", async () => {
|
|
3351
|
+
// A tag with query-reserved characters must be percent-encoded, not
|
|
3352
|
+
// concatenated raw into `?tags=...`. Without encoding, `a&injected=1`
|
|
3353
|
+
// splits into tags="a" plus a smuggled `injected=1` query param.
|
|
3354
|
+
const client = createTestClient();
|
|
3355
|
+
|
|
3356
|
+
mockTokenResponse();
|
|
3357
|
+
mockJsonResponse([]);
|
|
3358
|
+
|
|
3359
|
+
await client.listUserFunctions(["a&injected=1", "b"]);
|
|
3360
|
+
|
|
3361
|
+
const [url] = mockFetch.mock.calls[1];
|
|
3362
|
+
const parsed = new URL(url as string);
|
|
3363
|
+
expect(parsed.searchParams.get("tags")).toBe("a&injected=1,b");
|
|
3364
|
+
expect(parsed.searchParams.has("injected")).toBe(false);
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3241
3367
|
it("findById encodes a reserved-char id (a/b -> a%2Fb)", async () => {
|
|
3242
3368
|
const client = createTestClient();
|
|
3243
3369
|
|
package/src/client.ts
CHANGED
|
@@ -1051,40 +1051,77 @@ export class EkoDBClient {
|
|
|
1051
1051
|
|
|
1052
1052
|
/**
|
|
1053
1053
|
* Batch update multiple documents
|
|
1054
|
+
* @param collection - Collection name
|
|
1055
|
+
* @param updates - Array of updates ({ id, data, bypassRipple? })
|
|
1056
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
1057
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
1058
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
1059
|
+
* immediately — mirrors the single-record update.
|
|
1054
1060
|
*/
|
|
1055
1061
|
async batchUpdate(
|
|
1056
1062
|
collection: string,
|
|
1057
1063
|
updates: Array<{ id: string; data: Record; bypassRipple?: boolean }>,
|
|
1064
|
+
options?: BatchUpdateOptions,
|
|
1058
1065
|
): Promise<BatchOperationResult> {
|
|
1066
|
+
const params = new URLSearchParams();
|
|
1067
|
+
if (options?.bypassRipple !== undefined) {
|
|
1068
|
+
params.append("bypass_ripple", String(options.bypassRipple));
|
|
1069
|
+
}
|
|
1070
|
+
if (options?.transactionId) {
|
|
1071
|
+
params.append("transaction_id", options.transactionId);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1059
1074
|
const formattedUpdates = updates.map((u) => ({
|
|
1060
1075
|
id: u.id,
|
|
1061
1076
|
data: u.data,
|
|
1062
1077
|
bypass_ripple: u.bypassRipple,
|
|
1063
1078
|
}));
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
`/api/batch/update/${encodeURIComponent(collection)}
|
|
1067
|
-
|
|
1068
|
-
|
|
1079
|
+
const url = params.toString()
|
|
1080
|
+
? `/api/batch/update/${encodeURIComponent(collection)}?${params.toString()}`
|
|
1081
|
+
: `/api/batch/update/${encodeURIComponent(collection)}`;
|
|
1082
|
+
|
|
1083
|
+
return this.makeRequest<BatchOperationResult>("PUT", url, {
|
|
1084
|
+
updates: formattedUpdates,
|
|
1085
|
+
});
|
|
1069
1086
|
}
|
|
1070
1087
|
|
|
1071
1088
|
/**
|
|
1072
1089
|
* Batch delete multiple documents
|
|
1090
|
+
* @param collection - Collection name
|
|
1091
|
+
* @param ids - Document IDs to delete
|
|
1092
|
+
* @param bypassRipple - Optional flag to bypass ripple propagation (legacy
|
|
1093
|
+
* positional form, kept for back-compat)
|
|
1094
|
+
* @param options - Optional parameters (bypassRipple, transactionId). When
|
|
1095
|
+
* `transactionId` is set, the batch is staged into that MVCC transaction
|
|
1096
|
+
* (sent as the `transaction_id` query param) instead of committed
|
|
1097
|
+
* immediately — mirrors the single-record delete.
|
|
1073
1098
|
*/
|
|
1074
1099
|
async batchDelete(
|
|
1075
1100
|
collection: string,
|
|
1076
1101
|
ids: string[],
|
|
1077
1102
|
bypassRipple?: boolean,
|
|
1103
|
+
options?: BatchDeleteOptions,
|
|
1078
1104
|
): Promise<BatchOperationResult> {
|
|
1105
|
+
// bypass_ripple is sent per-item in the body (existing behavior). The
|
|
1106
|
+
// effective value prefers the explicit positional arg, falling back to the
|
|
1107
|
+
// options object so callers can use either form.
|
|
1108
|
+
const effectiveBypassRipple =
|
|
1109
|
+
bypassRipple !== undefined ? bypassRipple : options?.bypassRipple;
|
|
1110
|
+
|
|
1111
|
+
const params = new URLSearchParams();
|
|
1112
|
+
if (options?.transactionId) {
|
|
1113
|
+
params.append("transaction_id", options.transactionId);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1079
1116
|
const deletes = ids.map((id) => ({
|
|
1080
1117
|
id: id,
|
|
1081
|
-
bypass_ripple:
|
|
1118
|
+
bypass_ripple: effectiveBypassRipple,
|
|
1082
1119
|
}));
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
`/api/batch/delete/${encodeURIComponent(collection)}
|
|
1086
|
-
|
|
1087
|
-
);
|
|
1120
|
+
const url = params.toString()
|
|
1121
|
+
? `/api/batch/delete/${encodeURIComponent(collection)}?${params.toString()}`
|
|
1122
|
+
: `/api/batch/delete/${encodeURIComponent(collection)}`;
|
|
1123
|
+
|
|
1124
|
+
return this.makeRequest<BatchOperationResult>("DELETE", url, { deletes });
|
|
1088
1125
|
}
|
|
1089
1126
|
|
|
1090
1127
|
/**
|
|
@@ -2014,6 +2051,29 @@ export class EkoDBClient {
|
|
|
2014
2051
|
);
|
|
2015
2052
|
}
|
|
2016
2053
|
|
|
2054
|
+
/**
|
|
2055
|
+
* Send a client tool keepalive (liveness ping) for an in-flight SSE chat stream.
|
|
2056
|
+
*
|
|
2057
|
+
* This is NOT a result: it does not unblock the tool loop or feed anything to the
|
|
2058
|
+
* LLM. It simply resets the server's per-tool wait deadline (governed by
|
|
2059
|
+
* `client_tool_timeout_secs`, default 60s) so that slow user confirmations or
|
|
2060
|
+
* long-running client tools don't get the turn timed out before
|
|
2061
|
+
* {@link submitChatToolResult} arrives. Send it periodically while a client tool
|
|
2062
|
+
* is still working. See ekoDB#530.
|
|
2063
|
+
*/
|
|
2064
|
+
async submitChatToolKeepalive(chatId: string, callId: string): Promise<void> {
|
|
2065
|
+
await this.makeRequest(
|
|
2066
|
+
"POST",
|
|
2067
|
+
`/api/chat/${encodeURIComponent(chatId)}/tool-result`,
|
|
2068
|
+
{
|
|
2069
|
+
call_id: callId,
|
|
2070
|
+
keepalive: true,
|
|
2071
|
+
},
|
|
2072
|
+
0,
|
|
2073
|
+
true,
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2017
2077
|
/**
|
|
2018
2078
|
* Send a message in an existing chat session via SSE streaming.
|
|
2019
2079
|
*
|
|
@@ -2426,7 +2486,11 @@ export class EkoDBClient {
|
|
|
2426
2486
|
* List all functions, optionally filtered by tags
|
|
2427
2487
|
*/
|
|
2428
2488
|
async listFunctions(tags?: string[]): Promise<UserFunction[]> {
|
|
2429
|
-
|
|
2489
|
+
// URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
|
|
2490
|
+
// containing query-reserved characters can't smuggle extra params.
|
|
2491
|
+
const params = tags
|
|
2492
|
+
? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
|
|
2493
|
+
: "";
|
|
2430
2494
|
return this.makeRequest<UserFunction[]>("GET", `/api/functions${params}`);
|
|
2431
2495
|
}
|
|
2432
2496
|
|
|
@@ -2506,7 +2570,11 @@ export class EkoDBClient {
|
|
|
2506
2570
|
* @returns Array of user functions
|
|
2507
2571
|
*/
|
|
2508
2572
|
async listUserFunctions(tags?: string[]): Promise<UserFunction[]> {
|
|
2509
|
-
|
|
2573
|
+
// URLSearchParams percent-encodes the value (`&`/`=`/`,`), so a tag
|
|
2574
|
+
// containing query-reserved characters can't smuggle extra params.
|
|
2575
|
+
const params = tags
|
|
2576
|
+
? `?${new URLSearchParams({ tags: tags.join(",") }).toString()}`
|
|
2577
|
+
: "";
|
|
2510
2578
|
return this.makeRequest<UserFunction[]>(
|
|
2511
2579
|
"GET",
|
|
2512
2580
|
`/api/functions${params}`,
|
package/src/search.ts
CHANGED
|
@@ -59,6 +59,14 @@ export interface SearchQuery {
|
|
|
59
59
|
select_fields?: string[];
|
|
60
60
|
/** Exclude these fields from results */
|
|
61
61
|
exclude_fields?: string[];
|
|
62
|
+
|
|
63
|
+
// Metadata pre-filter
|
|
64
|
+
/**
|
|
65
|
+
* Metadata pre-filter for text/vector/hybrid search. A canonical QueryExpression
|
|
66
|
+
* (the same shape produced by `QueryBuilder.build().filter`); only records
|
|
67
|
+
* matching the filter are considered as candidates before ranking.
|
|
68
|
+
*/
|
|
69
|
+
filters?: any;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -275,6 +283,16 @@ export class SearchQueryBuilder {
|
|
|
275
283
|
return this;
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Set a metadata pre-filter for text/vector/hybrid search. Accepts a canonical
|
|
288
|
+
* QueryExpression (the same shape produced by `QueryBuilder.build().filter`);
|
|
289
|
+
* only records matching the filter are considered before ranking.
|
|
290
|
+
*/
|
|
291
|
+
filters(filter: any): this {
|
|
292
|
+
this.query.filters = filter;
|
|
293
|
+
return this;
|
|
294
|
+
}
|
|
295
|
+
|
|
278
296
|
/**
|
|
279
297
|
* Build the final SearchQuery object
|
|
280
298
|
*/
|