@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 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
- }>): Promise<BatchOperationResult>;
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
- return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
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
- async batchDelete(collection, ids, bypassRipple) {
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: bypassRipple,
651
+ bypass_ripple: effectiveBypassRipple,
618
652
  }));
619
- return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
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
- const params = tags ? `?tags=${tags.join(",")}` : "";
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
- const params = tags ? `?tags=${tags.join(",")}` : "";
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
  /**
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- return this.makeRequest<BatchOperationResult>(
1065
- "PUT",
1066
- `/api/batch/update/${encodeURIComponent(collection)}`,
1067
- { updates: formattedUpdates },
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: bypassRipple,
1118
+ bypass_ripple: effectiveBypassRipple,
1082
1119
  }));
1083
- return this.makeRequest<BatchOperationResult>(
1084
- "DELETE",
1085
- `/api/batch/delete/${encodeURIComponent(collection)}`,
1086
- { deletes },
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
- const params = tags ? `?tags=${tags.join(",")}` : "";
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
- const params = tags ? `?tags=${tags.join(",")}` : "";
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
  */