@ekodb/ekodb-client 0.20.0 → 0.22.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.js CHANGED
@@ -402,8 +402,8 @@ class EkoDBClient {
402
402
  params.append("transaction_id", options.transactionId);
403
403
  }
404
404
  const url = params.toString()
405
- ? `/api/insert/${collection}?${params.toString()}`
406
- : `/api/insert/${collection}`;
405
+ ? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
406
+ : `/api/insert/${encodeURIComponent(collection)}`;
407
407
  return this.makeRequest("POST", url, data);
408
408
  }
409
409
  /**
@@ -428,15 +428,57 @@ class EkoDBClient {
428
428
  * const results = await client.find("users", { limit: 10 });
429
429
  * ```
430
430
  */
431
- async find(collection, query = {}) {
431
+ async find(collection, query = {}, options) {
432
432
  const queryObj = query instanceof query_builder_1.QueryBuilder ? query.build() : query;
433
- return this.makeRequest("POST", `/api/find/${collection}`, queryObj);
433
+ // bypass_ripple and transaction_id are query parameters — the same way every
434
+ // other method (insert/update/findById) carries bypass_ripple — not part of
435
+ // the FindBody. Hoist any bypass_ripple carried on the query object (e.g. from
436
+ // QueryBuilder.bypassRipple()) out of the body so it is ALWAYS sent as a query
437
+ // param; an explicit options.bypassRipple wins.
438
+ let body = queryObj;
439
+ let bypassRipple = options?.bypassRipple;
440
+ if (body && typeof body === "object" && "bypass_ripple" in body) {
441
+ const { bypass_ripple, ...rest } = body;
442
+ body = rest;
443
+ if (bypassRipple === undefined)
444
+ bypassRipple = bypass_ripple;
445
+ }
446
+ const params = new URLSearchParams();
447
+ if (options?.transactionId)
448
+ params.append("transaction_id", options.transactionId);
449
+ if (bypassRipple !== undefined)
450
+ params.append("bypass_ripple", String(bypassRipple));
451
+ const qs = params.toString();
452
+ const url = qs
453
+ ? `/api/find/${encodeURIComponent(collection)}?${qs}`
454
+ : `/api/find/${encodeURIComponent(collection)}`;
455
+ return this.makeRequest("POST", url, body);
434
456
  }
435
457
  /**
436
- * Find a document by ID
458
+ * Find a document by ID.
459
+ * @param options - Optional read options. `transactionId` reads within a
460
+ * transaction (read-your-writes); see {@link FindByIdOptions}.
437
461
  */
438
- async findById(collection, id) {
439
- return this.makeRequest("GET", `/api/find/${collection}/${id}`);
462
+ async findById(collection, id, options) {
463
+ const params = new URLSearchParams();
464
+ if (options?.selectFields?.length) {
465
+ params.append("select_fields", options.selectFields.join(","));
466
+ }
467
+ if (options?.excludeFields?.length) {
468
+ params.append("exclude_fields", options.excludeFields.join(","));
469
+ }
470
+ // bypass_ripple is a GET query param, the same way the non-transactional
471
+ // findById carries it; it rides alongside transaction_id when both are set.
472
+ if (options?.bypassRipple !== undefined) {
473
+ params.append("bypass_ripple", String(options.bypassRipple));
474
+ }
475
+ if (options?.transactionId) {
476
+ params.append("transaction_id", options.transactionId);
477
+ }
478
+ const url = params.toString()
479
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
480
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
481
+ return this.makeRequest("GET", url);
440
482
  }
441
483
  /**
442
484
  * Find a document by ID with field projection
@@ -444,8 +486,9 @@ class EkoDBClient {
444
486
  * @param id - Document ID
445
487
  * @param selectFields - Fields to include in the result
446
488
  * @param excludeFields - Fields to exclude from the result
489
+ * @param transactionId - Read within a transaction (read-your-writes)
447
490
  */
448
- async findByIdWithProjection(collection, id, selectFields, excludeFields) {
491
+ async findByIdWithProjection(collection, id, selectFields, excludeFields, transactionId) {
449
492
  const params = new URLSearchParams();
450
493
  if (selectFields?.length) {
451
494
  params.append("select_fields", selectFields.join(","));
@@ -453,9 +496,12 @@ class EkoDBClient {
453
496
  if (excludeFields?.length) {
454
497
  params.append("exclude_fields", excludeFields.join(","));
455
498
  }
499
+ if (transactionId) {
500
+ params.append("transaction_id", transactionId);
501
+ }
456
502
  const url = params.toString()
457
- ? `/api/find/${collection}/${id}?${params.toString()}`
458
- : `/api/find/${collection}/${id}`;
503
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
504
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
459
505
  return this.makeRequest("GET", url);
460
506
  }
461
507
  /**
@@ -474,8 +520,8 @@ class EkoDBClient {
474
520
  params.append("transaction_id", options.transactionId);
475
521
  }
476
522
  const url = params.toString()
477
- ? `/api/update/${collection}/${id}?${params.toString()}`
478
- : `/api/update/${collection}/${id}`;
523
+ ? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
524
+ : `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
479
525
  return this.makeRequest("PUT", url, record);
480
526
  }
481
527
  /**
@@ -492,7 +538,7 @@ class EkoDBClient {
492
538
  * @param value - The value for the action (omit for pop/shift/clear)
493
539
  */
494
540
  async updateWithAction(collection, id, action, field, value) {
495
- const url = `/api/update/${collection}/${id}/action/${action}`;
541
+ const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
496
542
  return this.makeRequest("PUT", url, {
497
543
  field,
498
544
  value: value ?? null,
@@ -509,7 +555,7 @@ class EkoDBClient {
509
555
  * @param actions - Array of [action, field, value] tuples
510
556
  */
511
557
  async updateWithActionSequence(collection, id, actions) {
512
- const url = `/api/update/sequence/${collection}/${id}`;
558
+ const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
513
559
  return this.makeRequest("PUT", url, actions);
514
560
  }
515
561
  /**
@@ -527,8 +573,8 @@ class EkoDBClient {
527
573
  params.append("transaction_id", options.transactionId);
528
574
  }
529
575
  const url = params.toString()
530
- ? `/api/delete/${collection}/${id}?${params.toString()}`
531
- : `/api/delete/${collection}/${id}`;
576
+ ? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
577
+ : `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
532
578
  await this.makeRequest("DELETE", url);
533
579
  }
534
580
  /**
@@ -547,8 +593,8 @@ class EkoDBClient {
547
593
  }
548
594
  const inserts = records.map((data) => ({ data }));
549
595
  const url = params.toString()
550
- ? `/api/batch/insert/${collection}?${params.toString()}`
551
- : `/api/batch/insert/${collection}`;
596
+ ? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
597
+ : `/api/batch/insert/${encodeURIComponent(collection)}`;
552
598
  return this.makeRequest("POST", url, { inserts });
553
599
  }
554
600
  /**
@@ -560,7 +606,7 @@ class EkoDBClient {
560
606
  data: u.data,
561
607
  bypass_ripple: u.bypassRipple,
562
608
  }));
563
- return this.makeRequest("PUT", `/api/batch/update/${collection}`, { updates: formattedUpdates });
609
+ return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
564
610
  }
565
611
  /**
566
612
  * Batch delete multiple documents
@@ -570,7 +616,7 @@ class EkoDBClient {
570
616
  id: id,
571
617
  bypass_ripple: bypassRipple,
572
618
  }));
573
- return this.makeRequest("DELETE", `/api/batch/delete/${collection}`, { deletes });
619
+ return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
574
620
  }
575
621
  /**
576
622
  * Set a key-value pair with optional TTL
@@ -598,6 +644,12 @@ class EkoDBClient {
598
644
  async kvDelete(key) {
599
645
  await this.makeRequest("DELETE", `/api/kv/delete/${encodeURIComponent(key)}`, undefined, 0, true);
600
646
  }
647
+ /**
648
+ * Clear the entire KV store (all keys in the namespace).
649
+ */
650
+ async kvClear() {
651
+ await this.makeRequest("DELETE", "/api/kv/clear", undefined, 0, true);
652
+ }
601
653
  /**
602
654
  * Batch get multiple keys
603
655
  * @param keys - Array of keys to retrieve
@@ -663,7 +715,18 @@ class EkoDBClient {
663
715
  // Transaction Operations
664
716
  // ============================================================================
665
717
  /**
666
- * Begin a new transaction
718
+ * Begin a new transaction.
719
+ *
720
+ * Transactions are buffered: statements issued with this `transactionId`
721
+ * (passed via the `transactionId` option on insert/update/delete/find/…) are
722
+ * staged and applied atomically only at {@link commitTransaction}. They are
723
+ * invisible to everyone else until commit, and visible to this transaction's
724
+ * own reads (read-your-writes) only when those reads also carry the
725
+ * `transactionId`. {@link rollbackTransaction} discards the staged writes.
726
+ * `commitTransaction` may reject with a conflict (HTTP 409) if a record this
727
+ * transaction read or wrote was changed by another committed transaction —
728
+ * retry the transaction in that case.
729
+ *
667
730
  * @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
668
731
  * @returns Transaction ID
669
732
  */
@@ -687,12 +750,31 @@ class EkoDBClient {
687
750
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/commit`, undefined, 0, true);
688
751
  }
689
752
  /**
690
- * Rollback a transaction
753
+ * Rollback a transaction (discards all staged writes; nothing was applied).
691
754
  * @param transactionId - The transaction ID to rollback
692
755
  */
693
756
  async rollbackTransaction(transactionId) {
694
757
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/rollback`, undefined, 0, true);
695
758
  }
759
+ /**
760
+ * Create a savepoint within a transaction. A later
761
+ * {@link rollbackToSavepoint} discards everything staged after it.
762
+ */
763
+ async createSavepoint(transactionId, name) {
764
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints`, { name }, 0, true);
765
+ }
766
+ /**
767
+ * Roll the transaction back to a savepoint, discarding writes staged after it.
768
+ */
769
+ async rollbackToSavepoint(transactionId, name) {
770
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}/rollback`, undefined, 0, true);
771
+ }
772
+ /**
773
+ * Release (forget) a savepoint. Staged work is unaffected.
774
+ */
775
+ async releaseSavepoint(transactionId, name) {
776
+ await this.makeRequest("DELETE", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}`, undefined, 0, true);
777
+ }
696
778
  // ============================================================================
697
779
  // Convenience Methods
698
780
  // ============================================================================
@@ -833,11 +915,18 @@ class EkoDBClient {
833
915
  const result = await this.makeRequest("GET", "/api/collections", undefined, 0, true);
834
916
  return result.collections;
835
917
  }
918
+ /**
919
+ * List collections, excluding internal chat/system collections.
920
+ */
921
+ async listUserCollections() {
922
+ const result = await this.makeRequest("GET", "/api/collections?exclude_internal=true", undefined, 0, true);
923
+ return result.collections;
924
+ }
836
925
  /**
837
926
  * Delete a collection
838
927
  */
839
928
  async deleteCollection(collection) {
840
- await this.makeRequest("DELETE", `/api/collections/${collection}`, undefined, 0, true);
929
+ await this.makeRequest("DELETE", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
841
930
  }
842
931
  /**
843
932
  * Restore a deleted record from trash
@@ -848,7 +937,7 @@ class EkoDBClient {
848
937
  * @returns true if restored successfully
849
938
  */
850
939
  async restoreRecord(collection, id) {
851
- const result = await this.makeRequest("POST", `/api/trash/${collection}/${id}`, undefined, 0, true);
940
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, undefined, 0, true);
852
941
  return result.status === "restored";
853
942
  }
854
943
  /**
@@ -859,7 +948,7 @@ class EkoDBClient {
859
948
  * @returns Number of records restored
860
949
  */
861
950
  async restoreCollection(collection) {
862
- const result = await this.makeRequest("POST", `/api/trash/${collection}`, undefined, 0, true);
951
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}`, undefined, 0, true);
863
952
  return { recordsRestored: result.records_restored };
864
953
  }
865
954
  /**
@@ -880,7 +969,7 @@ class EkoDBClient {
880
969
  */
881
970
  async createCollection(collection, schema) {
882
971
  const schemaObj = schema instanceof schema_1.SchemaBuilder ? schema.build() : schema;
883
- await this.makeRequest("POST", `/api/collections/${collection}`, schemaObj, 0, true);
972
+ await this.makeRequest("POST", `/api/collections/${encodeURIComponent(collection)}`, schemaObj, 0, true);
884
973
  }
885
974
  /**
886
975
  * Get collection metadata and schema
@@ -889,7 +978,7 @@ class EkoDBClient {
889
978
  * @returns Collection metadata including schema and analytics
890
979
  */
891
980
  async getCollection(collection) {
892
- return this.makeRequest("GET", `/api/collections/${collection}`, undefined, 0, true);
981
+ return this.makeRequest("GET", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
893
982
  }
894
983
  /**
895
984
  * Get collection schema
@@ -936,7 +1025,7 @@ class EkoDBClient {
936
1025
  */
937
1026
  async search(collection, query) {
938
1027
  // Ensure all parameters from SearchQuery are sent to server
939
- return this.makeRequest("POST", `/api/search/${collection}`, query, 0, true);
1028
+ return this.makeRequest("POST", `/api/search/${encodeURIComponent(collection)}`, query, 0, true);
940
1029
  }
941
1030
  /**
942
1031
  * Get distinct (unique) values for a field across all records in a collection.
@@ -966,7 +1055,7 @@ class EkoDBClient {
966
1055
  body.bypass_ripple = options.bypassRipple;
967
1056
  if (options.bypassCache !== undefined)
968
1057
  body.bypass_cache = options.bypassCache;
969
- return this.makeRequest("POST", `/api/distinct/${collection}/${field}`, body, 0, true);
1058
+ return this.makeRequest("POST", `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`, body, 0, true);
970
1059
  }
971
1060
  /**
972
1061
  * Health check - verify the ekoDB server is responding
@@ -1146,20 +1235,36 @@ class EkoDBClient {
1146
1235
  * Send a message in an existing chat session
1147
1236
  */
1148
1237
  async chatMessage(sessionId, request) {
1149
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages`, request, 0, true);
1238
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages`, request, 0, true);
1150
1239
  }
1151
1240
  /**
1152
1241
  * Submit a client tool result for an in-flight SSE chat stream.
1153
1242
  * Unblocks ekoDB's tool loop so it can feed the result to the LLM.
1154
1243
  */
1155
1244
  async submitChatToolResult(chatId, callId, success, result, error) {
1156
- await this.makeRequest("POST", `/api/chat/${chatId}/tool-result`, {
1245
+ await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
1157
1246
  call_id: callId,
1158
1247
  success,
1159
1248
  ...(result !== undefined && { result }),
1160
1249
  ...(error !== undefined && { error }),
1161
1250
  }, 0, true);
1162
1251
  }
1252
+ /**
1253
+ * Send a client tool keepalive (liveness ping) for an in-flight SSE chat stream.
1254
+ *
1255
+ * This is NOT a result: it does not unblock the tool loop or feed anything to the
1256
+ * LLM. It simply resets the server's per-tool wait deadline (governed by
1257
+ * `client_tool_timeout_secs`, default 60s) so that slow user confirmations or
1258
+ * long-running client tools don't get the turn timed out before
1259
+ * {@link submitChatToolResult} arrives. Send it periodically while a client tool
1260
+ * is still working. See ekoDB#530.
1261
+ */
1262
+ async submitChatToolKeepalive(chatId, callId) {
1263
+ await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
1264
+ call_id: callId,
1265
+ keepalive: true,
1266
+ }, 0, true);
1267
+ }
1163
1268
  /**
1164
1269
  * Send a message in an existing chat session via SSE streaming.
1165
1270
  *
@@ -1180,7 +1285,7 @@ class EkoDBClient {
1180
1285
  await this.refreshToken();
1181
1286
  token = await this.getToken();
1182
1287
  }
1183
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
1288
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1184
1289
  const response = await fetch(url, {
1185
1290
  method: "POST",
1186
1291
  headers: {
@@ -1277,7 +1382,7 @@ class EkoDBClient {
1277
1382
  * Get a chat session by ID
1278
1383
  */
1279
1384
  async getChatSession(sessionId) {
1280
- return this.makeRequest("GET", `/api/chat/${sessionId}`, undefined, 0, true);
1385
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1281
1386
  }
1282
1387
  /**
1283
1388
  * List all chat sessions
@@ -1307,15 +1412,15 @@ class EkoDBClient {
1307
1412
  params.append("sort", query.sort);
1308
1413
  const queryString = params.toString();
1309
1414
  const path = queryString
1310
- ? `/api/chat/${sessionId}/messages?${queryString}`
1311
- : `/api/chat/${sessionId}/messages`;
1415
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
1416
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
1312
1417
  return this.makeRequest("GET", path, undefined, 0, true);
1313
1418
  }
1314
1419
  /**
1315
1420
  * Update a chat session
1316
1421
  */
1317
1422
  async updateChatSession(sessionId, request) {
1318
- return this.makeRequest("PUT", `/api/chat/${sessionId}`, request, 0, true);
1423
+ return this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}`, request, 0, true);
1319
1424
  }
1320
1425
  /**
1321
1426
  * Branch a chat session
@@ -1327,31 +1432,31 @@ class EkoDBClient {
1327
1432
  * Delete a chat session
1328
1433
  */
1329
1434
  async deleteChatSession(sessionId) {
1330
- await this.makeRequest("DELETE", `/api/chat/${sessionId}`, undefined, 0, true);
1435
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1331
1436
  }
1332
1437
  /**
1333
1438
  * Regenerate an AI response message
1334
1439
  */
1335
1440
  async regenerateMessage(sessionId, messageId) {
1336
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages/${messageId}/regenerate`, undefined, 0, true);
1441
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`, undefined, 0, true);
1337
1442
  }
1338
1443
  /**
1339
1444
  * Update a specific message
1340
1445
  */
1341
1446
  async updateChatMessage(sessionId, messageId, content) {
1342
- await this.makeRequest("PUT", `/api/chat/${sessionId}/messages/${messageId}`, { content }, 0, true);
1447
+ await this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { content }, 0, true);
1343
1448
  }
1344
1449
  /**
1345
1450
  * Delete a specific message
1346
1451
  */
1347
1452
  async deleteChatMessage(sessionId, messageId) {
1348
- await this.makeRequest("DELETE", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1453
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1349
1454
  }
1350
1455
  /**
1351
1456
  * Toggle the "forgotten" status of a message
1352
1457
  */
1353
1458
  async toggleForgottenMessage(sessionId, messageId, forgotten) {
1354
- await this.makeRequest("PATCH", `/api/chat/${sessionId}/messages/${messageId}/forgotten`, { forgotten }, 0, true);
1459
+ await this.makeRequest("PATCH", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`, { forgotten }, 0, true);
1355
1460
  }
1356
1461
  /**
1357
1462
  * Compact a chat session's history on demand.
@@ -1368,7 +1473,7 @@ class EkoDBClient {
1368
1473
  if (keepRecent !== undefined) {
1369
1474
  body.keep_recent = keepRecent;
1370
1475
  }
1371
- return this.makeRequest("POST", `/api/chat/${chatId}/compact`, body, 0, true);
1476
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/compact`, body, 0, true);
1372
1477
  }
1373
1478
  /**
1374
1479
  * Merge multiple chat sessions into one
@@ -1406,7 +1511,7 @@ class EkoDBClient {
1406
1511
  * @returns The chat message record
1407
1512
  */
1408
1513
  async getChatMessage(sessionId, messageId) {
1409
- return this.makeRequest("GET", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1514
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1410
1515
  }
1411
1516
  // ========================================================================
1412
1517
  // SCRIPTS API
@@ -1422,7 +1527,7 @@ class EkoDBClient {
1422
1527
  * Get a function by ID
1423
1528
  */
1424
1529
  async getFunction(id) {
1425
- return this.makeRequest("GET", `/api/functions/${id}`);
1530
+ return this.makeRequest("GET", `/api/functions/${encodeURIComponent(id)}`);
1426
1531
  }
1427
1532
  /**
1428
1533
  * List all functions, optionally filtered by tags
@@ -1435,19 +1540,19 @@ class EkoDBClient {
1435
1540
  * Update an existing function by ID
1436
1541
  */
1437
1542
  async updateFunction(id, script) {
1438
- await this.makeRequest("PUT", `/api/functions/${id}`, script);
1543
+ await this.makeRequest("PUT", `/api/functions/${encodeURIComponent(id)}`, script);
1439
1544
  }
1440
1545
  /**
1441
1546
  * Delete a function by ID
1442
1547
  */
1443
1548
  async deleteFunction(id) {
1444
- await this.makeRequest("DELETE", `/api/functions/${id}`);
1549
+ await this.makeRequest("DELETE", `/api/functions/${encodeURIComponent(id)}`);
1445
1550
  }
1446
1551
  /**
1447
1552
  * Call a saved function by ID or label
1448
1553
  */
1449
1554
  async callFunction(idOrLabel, params) {
1450
- return this.makeRequest("POST", `/api/functions/${idOrLabel}`, params || {});
1555
+ return this.makeRequest("POST", `/api/functions/${encodeURIComponent(idOrLabel)}`, params || {});
1451
1556
  }
1452
1557
  // ========================================================================
1453
1558
  // USER FUNCTIONS API
@@ -1556,15 +1661,15 @@ class EkoDBClient {
1556
1661
  }
1557
1662
  /** Start a goal step (status -> in_progress) */
1558
1663
  async goalStepStart(id, stepIndex) {
1559
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`, undefined, 0, true);
1664
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`, undefined, 0, true);
1560
1665
  }
1561
1666
  /** Complete a goal step with result */
1562
1667
  async goalStepComplete(id, stepIndex, data) {
1563
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`, data, 0, true);
1668
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`, data, 0, true);
1564
1669
  }
1565
1670
  /** Fail a goal step with error */
1566
1671
  async goalStepFail(id, stepIndex, data) {
1567
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`, data, 0, true);
1672
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`, data, 0, true);
1568
1673
  }
1569
1674
  // ========================================================================
1570
1675
  // TASK API
@@ -2099,6 +2204,13 @@ class WebSocketClient {
2099
2204
  this.ws = null;
2100
2205
  this.dispatcherRunning = false;
2101
2206
  this.schemaCache = null;
2207
+ /**
2208
+ * Per-connection wire format, set by negotiateFormat() on every (re)connect:
2209
+ * true once the server has Welcomed msgpack, so frames are sent/received as
2210
+ * binary msgpack; false (JSON text) otherwise, including against an older
2211
+ * server that never Welcomes. Keeps the transport fully back-compatible.
2212
+ */
2213
+ this.binary = false;
2102
2214
  // Reconnect state
2103
2215
  /** Set while close() is in progress so the close handler doesn't reconnect. */
2104
2216
  this.closed = false;
@@ -2180,8 +2292,66 @@ class WebSocketClient {
2180
2292
  this.ws.on("open", () => resolve());
2181
2293
  this.ws.on("error", (err) => reject(err));
2182
2294
  });
2295
+ // Negotiate the wire format before the dispatcher starts so the Welcome is
2296
+ // consumed here (not by routeMessage), and before any real frame is sent
2297
+ // (resubscribeAll runs only after this resolves).
2298
+ await this.negotiateFormat(this.ws);
2183
2299
  this.spawnDispatcher();
2184
2300
  }
2301
+ /**
2302
+ * Additive capability handshake: offer msgpack and, if the server Welcomes
2303
+ * it, switch this connection to binary msgpack frames; otherwise stay on JSON
2304
+ * text. The Welcome (a text frame) is read with a one-shot listener and a
2305
+ * timeout so an older server that never answers — or answers with an Error —
2306
+ * simply leaves the connection on JSON. Best-effort and never throws: JSON
2307
+ * always works.
2308
+ */
2309
+ async negotiateFormat(socket) {
2310
+ this.binary = false;
2311
+ const welcome = await new Promise((resolve) => {
2312
+ const onMsg = (data) => {
2313
+ clearTimeout(timer);
2314
+ try {
2315
+ resolve(JSON.parse(data.toString()));
2316
+ }
2317
+ catch {
2318
+ resolve(null);
2319
+ }
2320
+ };
2321
+ // Only caps the wait when no Welcome comes (a silent/old server); the
2322
+ // listener resolves immediately when it does arrive. 2s comfortably exceeds
2323
+ // the handshake round-trip even on high-latency links.
2324
+ const timer = setTimeout(() => {
2325
+ socket.off("message", onMsg);
2326
+ resolve(null);
2327
+ }, 2000);
2328
+ socket.once("message", onMsg);
2329
+ try {
2330
+ socket.send(JSON.stringify({
2331
+ type: "Hello",
2332
+ payload: { formats: ["msgpack", "json"] },
2333
+ }));
2334
+ }
2335
+ catch {
2336
+ clearTimeout(timer);
2337
+ socket.off("message", onMsg);
2338
+ resolve(null);
2339
+ }
2340
+ });
2341
+ if (welcome &&
2342
+ welcome.type === "Welcome" &&
2343
+ welcome.payload?.format === "msgpack") {
2344
+ this.binary = true;
2345
+ }
2346
+ }
2347
+ /**
2348
+ * Send a request object on the active socket using the negotiated format:
2349
+ * binary msgpack when the server Welcomed it, JSON text otherwise. The single
2350
+ * write point so every request honors the negotiated transport.
2351
+ */
2352
+ sendFrame(obj) {
2353
+ this.ws.send(this.binary ? (0, msgpack_1.encode)(obj) : JSON.stringify(obj));
2354
+ }
2185
2355
  spawnDispatcher() {
2186
2356
  if (this.dispatcherRunning)
2187
2357
  return;
@@ -2190,11 +2360,16 @@ class WebSocketClient {
2190
2360
  // socket may still emit late close/error events; ignore them so they don't
2191
2361
  // tear down the replacement connection.
2192
2362
  const socket = this.ws;
2193
- socket.on("message", (data) => {
2363
+ socket.on("message", (data, isBinary) => {
2194
2364
  if (this.ws !== socket)
2195
2365
  return;
2196
2366
  try {
2197
- const msg = JSON.parse(data.toString());
2367
+ // A binary frame is msgpack (the server only sends binary once it has
2368
+ // Welcomed msgpack); a text frame is JSON. Decode by frame type so the
2369
+ // routed value is identical regardless of negotiated transport.
2370
+ const msg = isBinary
2371
+ ? (0, msgpack_1.decode)(data)
2372
+ : JSON.parse(data.toString());
2198
2373
  this.routeMessage(msg);
2199
2374
  }
2200
2375
  catch {
@@ -2477,7 +2652,7 @@ class WebSocketClient {
2477
2652
  }
2478
2653
  this.pendingRequests.set(messageId, { resolve, reject, timer });
2479
2654
  try {
2480
- this.ws.send(JSON.stringify(request));
2655
+ this.sendFrame(request);
2481
2656
  }
2482
2657
  catch (err) {
2483
2658
  this.pendingRequests.delete(messageId);
@@ -2560,6 +2735,27 @@ class WebSocketClient {
2560
2735
  if (stream && !stream.closed) {
2561
2736
  stream.close();
2562
2737
  }
2738
+ // Best-effort: tell the server to stop streaming this collection (the
2739
+ // server already handles an Unsubscribe frame). If the socket isn't open
2740
+ // the local teardown above suffices, since the server drops subscriptions
2741
+ // when the connection closes. A unique messageId is attached so the
2742
+ // server's Success ack carries a correlation id: it has no pending request
2743
+ // to match, so it is simply ignored — and because the id is present, the
2744
+ // single-pending fallback can't misroute it to an unrelated request.
2745
+ if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
2746
+ try {
2747
+ this.sendFrame({
2748
+ type: "Unsubscribe",
2749
+ messageId: this.genMessageId(),
2750
+ payload: { collection },
2751
+ });
2752
+ }
2753
+ catch {
2754
+ // Best-effort: the socket can close between the readyState check and the
2755
+ // send. Local teardown already happened, so swallow the failure rather
2756
+ // than throw out of a void teardown call.
2757
+ }
2758
+ }
2563
2759
  }
2564
2760
  /**
2565
2761
  * Send a chat message and receive a streaming response.
@@ -2588,7 +2784,7 @@ class WebSocketClient {
2588
2784
  ...(options?.excludeTools && { exclude_tools: options.excludeTools }),
2589
2785
  },
2590
2786
  };
2591
- this.ws.send(JSON.stringify(request));
2787
+ this.sendFrame(request);
2592
2788
  return stream;
2593
2789
  }
2594
2790
  /**
@@ -2608,7 +2804,7 @@ class WebSocketClient {
2608
2804
  resolve: () => resolve(),
2609
2805
  reject: (err) => reject(err),
2610
2806
  };
2611
- this.ws.send(JSON.stringify(request));
2807
+ this.sendFrame(request);
2612
2808
  });
2613
2809
  }
2614
2810
  /**
@@ -2626,7 +2822,24 @@ class WebSocketClient {
2626
2822
  ...(error !== undefined && { error }),
2627
2823
  },
2628
2824
  };
2629
- this.ws.send(JSON.stringify(request));
2825
+ this.sendFrame(request);
2826
+ }
2827
+ /**
2828
+ * Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
2829
+ * stop generating tokens for the given chat.
2830
+ */
2831
+ async cancelChat(chatId) {
2832
+ await this.ensureConnected();
2833
+ // Attach a unique messageId (same generator as unsubscribe). Any Success ack
2834
+ // from the server then carries a correlation id: it has no pending request to
2835
+ // match, so it is ignored — and because the id is present, the dispatcher's
2836
+ // single-pending fallback can't misroute the ack to an unrelated request.
2837
+ const request = {
2838
+ type: "CancelChat",
2839
+ messageId: this.genMessageId(),
2840
+ payload: { chat_id: chatId },
2841
+ };
2842
+ this.sendFrame(request);
2630
2843
  }
2631
2844
  /**
2632
2845
  * Stateless raw LLM completion via WebSocket.