@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/src/client.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { encode, decode } from "@msgpack/msgpack";
6
- import { QueryBuilder, Query as QueryBuilderQuery } from "./query-builder";
6
+ import { QueryBuilder, Query } from "./query-builder";
7
7
  import { SearchQuery, SearchResponse } from "./search";
8
8
  import { Schema, SchemaBuilder, CollectionMetadata } from "./schema";
9
9
  import { UserFunction, FunctionResult } from "./functions";
@@ -65,11 +65,11 @@ export class RateLimitError extends Error {
65
65
  }
66
66
  }
67
67
 
68
- export interface Query {
69
- limit?: number;
70
- offset?: number;
71
- filter?: Record;
72
- }
68
+ // `Query` is the canonical find/query body shape — the server's FindBody —
69
+ // re-exported from the query builder so the whole client shares a single type
70
+ // (`filter`, `sort`, `limit`, `skip`, `join`, `select_fields`, `exclude_fields`,
71
+ // matching the server exactly). `QueryBuilder.build()` returns this same type.
72
+ export type { Query };
73
73
 
74
74
  export interface BatchOperationResult {
75
75
  successful: string[];
@@ -115,6 +115,24 @@ export interface FindOptions {
115
115
  bypassCache?: boolean;
116
116
  selectFields?: string[];
117
117
  excludeFields?: string[];
118
+ /**
119
+ * Read within a transaction (read-your-writes). When set, the read is served
120
+ * from the transaction's own view — its uncommitted staged writes, else the
121
+ * committed store — and recorded in the transaction's read set for
122
+ * commit-time conflict detection. Omit for an ordinary committed read.
123
+ */
124
+ transactionId?: string;
125
+ }
126
+
127
+ /**
128
+ * Options for a point read by id. `transactionId` enables read-your-writes
129
+ * within a transaction (see {@link FindOptions.transactionId}).
130
+ */
131
+ export interface FindByIdOptions {
132
+ selectFields?: string[];
133
+ excludeFields?: string[];
134
+ bypassRipple?: boolean;
135
+ transactionId?: string;
118
136
  }
119
137
 
120
138
  export interface BatchInsertOptions {
@@ -782,8 +800,8 @@ export class EkoDBClient {
782
800
  }
783
801
 
784
802
  const url = params.toString()
785
- ? `/api/insert/${collection}?${params.toString()}`
786
- : `/api/insert/${collection}`;
803
+ ? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
804
+ : `/api/insert/${encodeURIComponent(collection)}`;
787
805
 
788
806
  return this.makeRequest<Record>("POST", url, data);
789
807
  }
@@ -813,20 +831,64 @@ export class EkoDBClient {
813
831
  async find(
814
832
  collection: string,
815
833
  query: Query | QueryBuilder = {},
834
+ options?: { bypassRipple?: boolean; transactionId?: string },
816
835
  ): Promise<Record[]> {
817
836
  const queryObj = query instanceof QueryBuilder ? query.build() : query;
818
- return this.makeRequest<Record[]>(
819
- "POST",
820
- `/api/find/${collection}`,
821
- queryObj,
822
- );
837
+ // bypass_ripple and transaction_id are query parameters — the same way every
838
+ // other method (insert/update/findById) carries bypass_ripple — not part of
839
+ // the FindBody. Hoist any bypass_ripple carried on the query object (e.g. from
840
+ // QueryBuilder.bypassRipple()) out of the body so it is ALWAYS sent as a query
841
+ // param; an explicit options.bypassRipple wins.
842
+ let body: unknown = queryObj;
843
+ let bypassRipple = options?.bypassRipple;
844
+ if (body && typeof body === "object" && "bypass_ripple" in body) {
845
+ const { bypass_ripple, ...rest } = body as Record & {
846
+ bypass_ripple?: boolean;
847
+ };
848
+ body = rest;
849
+ if (bypassRipple === undefined) bypassRipple = bypass_ripple;
850
+ }
851
+ const params = new URLSearchParams();
852
+ if (options?.transactionId)
853
+ params.append("transaction_id", options.transactionId);
854
+ if (bypassRipple !== undefined)
855
+ params.append("bypass_ripple", String(bypassRipple));
856
+ const qs = params.toString();
857
+ const url = qs
858
+ ? `/api/find/${encodeURIComponent(collection)}?${qs}`
859
+ : `/api/find/${encodeURIComponent(collection)}`;
860
+ return this.makeRequest<Record[]>("POST", url, body);
823
861
  }
824
862
 
825
863
  /**
826
- * Find a document by ID
864
+ * Find a document by ID.
865
+ * @param options - Optional read options. `transactionId` reads within a
866
+ * transaction (read-your-writes); see {@link FindByIdOptions}.
827
867
  */
828
- async findById(collection: string, id: string): Promise<Record> {
829
- return this.makeRequest<Record>("GET", `/api/find/${collection}/${id}`);
868
+ async findById(
869
+ collection: string,
870
+ id: string,
871
+ options?: FindByIdOptions,
872
+ ): Promise<Record> {
873
+ const params = new URLSearchParams();
874
+ if (options?.selectFields?.length) {
875
+ params.append("select_fields", options.selectFields.join(","));
876
+ }
877
+ if (options?.excludeFields?.length) {
878
+ params.append("exclude_fields", options.excludeFields.join(","));
879
+ }
880
+ // bypass_ripple is a GET query param, the same way the non-transactional
881
+ // findById carries it; it rides alongside transaction_id when both are set.
882
+ if (options?.bypassRipple !== undefined) {
883
+ params.append("bypass_ripple", String(options.bypassRipple));
884
+ }
885
+ if (options?.transactionId) {
886
+ params.append("transaction_id", options.transactionId);
887
+ }
888
+ const url = params.toString()
889
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
890
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
891
+ return this.makeRequest<Record>("GET", url);
830
892
  }
831
893
 
832
894
  /**
@@ -835,12 +897,14 @@ export class EkoDBClient {
835
897
  * @param id - Document ID
836
898
  * @param selectFields - Fields to include in the result
837
899
  * @param excludeFields - Fields to exclude from the result
900
+ * @param transactionId - Read within a transaction (read-your-writes)
838
901
  */
839
902
  async findByIdWithProjection(
840
903
  collection: string,
841
904
  id: string,
842
905
  selectFields?: string[],
843
906
  excludeFields?: string[],
907
+ transactionId?: string,
844
908
  ): Promise<Record> {
845
909
  const params = new URLSearchParams();
846
910
  if (selectFields?.length) {
@@ -849,9 +913,12 @@ export class EkoDBClient {
849
913
  if (excludeFields?.length) {
850
914
  params.append("exclude_fields", excludeFields.join(","));
851
915
  }
916
+ if (transactionId) {
917
+ params.append("transaction_id", transactionId);
918
+ }
852
919
  const url = params.toString()
853
- ? `/api/find/${collection}/${id}?${params.toString()}`
854
- : `/api/find/${collection}/${id}`;
920
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
921
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
855
922
  return this.makeRequest<Record>("GET", url);
856
923
  }
857
924
 
@@ -877,8 +944,8 @@ export class EkoDBClient {
877
944
  }
878
945
 
879
946
  const url = params.toString()
880
- ? `/api/update/${collection}/${id}?${params.toString()}`
881
- : `/api/update/${collection}/${id}`;
947
+ ? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
948
+ : `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
882
949
 
883
950
  return this.makeRequest<Record>("PUT", url, record);
884
951
  }
@@ -903,7 +970,7 @@ export class EkoDBClient {
903
970
  field: string,
904
971
  value?: any,
905
972
  ): Promise<Record> {
906
- const url = `/api/update/${collection}/${id}/action/${action}`;
973
+ const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
907
974
  return this.makeRequest<Record>("PUT", url, {
908
975
  field,
909
976
  value: value ?? null,
@@ -925,7 +992,7 @@ export class EkoDBClient {
925
992
  id: string,
926
993
  actions: [string, string, any][],
927
994
  ): Promise<Record> {
928
- const url = `/api/update/sequence/${collection}/${id}`;
995
+ const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
929
996
  return this.makeRequest<Record>("PUT", url, actions);
930
997
  }
931
998
 
@@ -949,8 +1016,8 @@ export class EkoDBClient {
949
1016
  }
950
1017
 
951
1018
  const url = params.toString()
952
- ? `/api/delete/${collection}/${id}?${params.toString()}`
953
- : `/api/delete/${collection}/${id}`;
1019
+ ? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
1020
+ : `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
954
1021
 
955
1022
  await this.makeRequest<void>("DELETE", url);
956
1023
  }
@@ -976,8 +1043,8 @@ export class EkoDBClient {
976
1043
 
977
1044
  const inserts = records.map((data) => ({ data }));
978
1045
  const url = params.toString()
979
- ? `/api/batch/insert/${collection}?${params.toString()}`
980
- : `/api/batch/insert/${collection}`;
1046
+ ? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
1047
+ : `/api/batch/insert/${encodeURIComponent(collection)}`;
981
1048
 
982
1049
  return this.makeRequest<BatchOperationResult>("POST", url, { inserts });
983
1050
  }
@@ -996,7 +1063,7 @@ export class EkoDBClient {
996
1063
  }));
997
1064
  return this.makeRequest<BatchOperationResult>(
998
1065
  "PUT",
999
- `/api/batch/update/${collection}`,
1066
+ `/api/batch/update/${encodeURIComponent(collection)}`,
1000
1067
  { updates: formattedUpdates },
1001
1068
  );
1002
1069
  }
@@ -1015,7 +1082,7 @@ export class EkoDBClient {
1015
1082
  }));
1016
1083
  return this.makeRequest<BatchOperationResult>(
1017
1084
  "DELETE",
1018
- `/api/batch/delete/${collection}`,
1085
+ `/api/batch/delete/${encodeURIComponent(collection)}`,
1019
1086
  { deletes },
1020
1087
  );
1021
1088
  }
@@ -1067,6 +1134,19 @@ export class EkoDBClient {
1067
1134
  );
1068
1135
  }
1069
1136
 
1137
+ /**
1138
+ * Clear the entire KV store (all keys in the namespace).
1139
+ */
1140
+ async kvClear(): Promise<void> {
1141
+ await this.makeRequest<void>(
1142
+ "DELETE",
1143
+ "/api/kv/clear",
1144
+ undefined,
1145
+ 0,
1146
+ true, // Force JSON for KV operations
1147
+ );
1148
+ }
1149
+
1070
1150
  /**
1071
1151
  * Batch get multiple keys
1072
1152
  * @param keys - Array of keys to retrieve
@@ -1171,7 +1251,18 @@ export class EkoDBClient {
1171
1251
  // ============================================================================
1172
1252
 
1173
1253
  /**
1174
- * Begin a new transaction
1254
+ * Begin a new transaction.
1255
+ *
1256
+ * Transactions are buffered: statements issued with this `transactionId`
1257
+ * (passed via the `transactionId` option on insert/update/delete/find/…) are
1258
+ * staged and applied atomically only at {@link commitTransaction}. They are
1259
+ * invisible to everyone else until commit, and visible to this transaction's
1260
+ * own reads (read-your-writes) only when those reads also carry the
1261
+ * `transactionId`. {@link rollbackTransaction} discards the staged writes.
1262
+ * `commitTransaction` may reject with a conflict (HTTP 409) if a record this
1263
+ * transaction read or wrote was changed by another committed transaction —
1264
+ * retry the transaction in that case.
1265
+ *
1175
1266
  * @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
1176
1267
  * @returns Transaction ID
1177
1268
  */
@@ -1220,7 +1311,7 @@ export class EkoDBClient {
1220
1311
  }
1221
1312
 
1222
1313
  /**
1223
- * Rollback a transaction
1314
+ * Rollback a transaction (discards all staged writes; nothing was applied).
1224
1315
  * @param transactionId - The transaction ID to rollback
1225
1316
  */
1226
1317
  async rollbackTransaction(transactionId: string): Promise<void> {
@@ -1233,6 +1324,49 @@ export class EkoDBClient {
1233
1324
  );
1234
1325
  }
1235
1326
 
1327
+ /**
1328
+ * Create a savepoint within a transaction. A later
1329
+ * {@link rollbackToSavepoint} discards everything staged after it.
1330
+ */
1331
+ async createSavepoint(transactionId: string, name: string): Promise<void> {
1332
+ await this.makeRequest<void>(
1333
+ "POST",
1334
+ `/api/transactions/${encodeURIComponent(transactionId)}/savepoints`,
1335
+ { name },
1336
+ 0,
1337
+ true,
1338
+ );
1339
+ }
1340
+
1341
+ /**
1342
+ * Roll the transaction back to a savepoint, discarding writes staged after it.
1343
+ */
1344
+ async rollbackToSavepoint(
1345
+ transactionId: string,
1346
+ name: string,
1347
+ ): Promise<void> {
1348
+ await this.makeRequest<void>(
1349
+ "POST",
1350
+ `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}/rollback`,
1351
+ undefined,
1352
+ 0,
1353
+ true,
1354
+ );
1355
+ }
1356
+
1357
+ /**
1358
+ * Release (forget) a savepoint. Staged work is unaffected.
1359
+ */
1360
+ async releaseSavepoint(transactionId: string, name: string): Promise<void> {
1361
+ await this.makeRequest<void>(
1362
+ "DELETE",
1363
+ `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}`,
1364
+ undefined,
1365
+ 0,
1366
+ true,
1367
+ );
1368
+ }
1369
+
1236
1370
  // ============================================================================
1237
1371
  // Convenience Methods
1238
1372
  // ============================================================================
@@ -1379,7 +1513,7 @@ export class EkoDBClient {
1379
1513
  ): Promise<Record[]> {
1380
1514
  // Page 1 = skip 0, Page 2 = skip pageSize, etc.
1381
1515
  const skip = page > 0 ? (page - 1) * pageSize : 0;
1382
- const query: QueryBuilderQuery = {
1516
+ const query: Query = {
1383
1517
  limit: pageSize,
1384
1518
  skip: skip,
1385
1519
  };
@@ -1400,13 +1534,27 @@ export class EkoDBClient {
1400
1534
  return result.collections;
1401
1535
  }
1402
1536
 
1537
+ /**
1538
+ * List collections, excluding internal chat/system collections.
1539
+ */
1540
+ async listUserCollections(): Promise<string[]> {
1541
+ const result = await this.makeRequest<{ collections: string[] }>(
1542
+ "GET",
1543
+ "/api/collections?exclude_internal=true",
1544
+ undefined,
1545
+ 0,
1546
+ true, // Force JSON for metadata operations
1547
+ );
1548
+ return result.collections;
1549
+ }
1550
+
1403
1551
  /**
1404
1552
  * Delete a collection
1405
1553
  */
1406
1554
  async deleteCollection(collection: string): Promise<void> {
1407
1555
  await this.makeRequest<void>(
1408
1556
  "DELETE",
1409
- `/api/collections/${collection}`,
1557
+ `/api/collections/${encodeURIComponent(collection)}`,
1410
1558
  undefined,
1411
1559
  0,
1412
1560
  true, // Force JSON for metadata operations
@@ -1424,7 +1572,7 @@ export class EkoDBClient {
1424
1572
  async restoreRecord(collection: string, id: string): Promise<boolean> {
1425
1573
  const result = await this.makeRequest<{ status: string }>(
1426
1574
  "POST",
1427
- `/api/trash/${collection}/${id}`,
1575
+ `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
1428
1576
  undefined,
1429
1577
  0,
1430
1578
  true,
@@ -1446,7 +1594,13 @@ export class EkoDBClient {
1446
1594
  status: string;
1447
1595
  collection: string;
1448
1596
  records_restored: number;
1449
- }>("POST", `/api/trash/${collection}`, undefined, 0, true);
1597
+ }>(
1598
+ "POST",
1599
+ `/api/trash/${encodeURIComponent(collection)}`,
1600
+ undefined,
1601
+ 0,
1602
+ true,
1603
+ );
1450
1604
  return { recordsRestored: result.records_restored };
1451
1605
  }
1452
1606
 
@@ -1473,7 +1627,7 @@ export class EkoDBClient {
1473
1627
  const schemaObj = schema instanceof SchemaBuilder ? schema.build() : schema;
1474
1628
  await this.makeRequest<void>(
1475
1629
  "POST",
1476
- `/api/collections/${collection}`,
1630
+ `/api/collections/${encodeURIComponent(collection)}`,
1477
1631
  schemaObj,
1478
1632
  0,
1479
1633
  true, // Force JSON for metadata operations
@@ -1489,7 +1643,7 @@ export class EkoDBClient {
1489
1643
  async getCollection(collection: string): Promise<CollectionMetadata> {
1490
1644
  return this.makeRequest<CollectionMetadata>(
1491
1645
  "GET",
1492
- `/api/collections/${collection}`,
1646
+ `/api/collections/${encodeURIComponent(collection)}`,
1493
1647
  undefined,
1494
1648
  0,
1495
1649
  true, // Force JSON for metadata operations
@@ -1547,7 +1701,7 @@ export class EkoDBClient {
1547
1701
  // Ensure all parameters from SearchQuery are sent to server
1548
1702
  return this.makeRequest<SearchResponse>(
1549
1703
  "POST",
1550
- `/api/search/${collection}`,
1704
+ `/api/search/${encodeURIComponent(collection)}`,
1551
1705
  query,
1552
1706
  0,
1553
1707
  true, // Force JSON for search operations
@@ -1592,7 +1746,7 @@ export class EkoDBClient {
1592
1746
 
1593
1747
  return this.makeRequest<DistinctValuesResponse>(
1594
1748
  "POST",
1595
- `/api/distinct/${collection}/${field}`,
1749
+ `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`,
1596
1750
  body,
1597
1751
  0,
1598
1752
  true, // Force JSON
@@ -1828,7 +1982,7 @@ export class EkoDBClient {
1828
1982
  ): Promise<ChatResponse> {
1829
1983
  return this.makeRequest<ChatResponse>(
1830
1984
  "POST",
1831
- `/api/chat/${sessionId}/messages`,
1985
+ `/api/chat/${encodeURIComponent(sessionId)}/messages`,
1832
1986
  request,
1833
1987
  0,
1834
1988
  true, // Force JSON for chat operations
@@ -1848,7 +2002,7 @@ export class EkoDBClient {
1848
2002
  ): Promise<void> {
1849
2003
  await this.makeRequest(
1850
2004
  "POST",
1851
- `/api/chat/${chatId}/tool-result`,
2005
+ `/api/chat/${encodeURIComponent(chatId)}/tool-result`,
1852
2006
  {
1853
2007
  call_id: callId,
1854
2008
  success,
@@ -1860,6 +2014,29 @@ export class EkoDBClient {
1860
2014
  );
1861
2015
  }
1862
2016
 
2017
+ /**
2018
+ * Send a client tool keepalive (liveness ping) for an in-flight SSE chat stream.
2019
+ *
2020
+ * This is NOT a result: it does not unblock the tool loop or feed anything to the
2021
+ * LLM. It simply resets the server's per-tool wait deadline (governed by
2022
+ * `client_tool_timeout_secs`, default 60s) so that slow user confirmations or
2023
+ * long-running client tools don't get the turn timed out before
2024
+ * {@link submitChatToolResult} arrives. Send it periodically while a client tool
2025
+ * is still working. See ekoDB#530.
2026
+ */
2027
+ async submitChatToolKeepalive(chatId: string, callId: string): Promise<void> {
2028
+ await this.makeRequest(
2029
+ "POST",
2030
+ `/api/chat/${encodeURIComponent(chatId)}/tool-result`,
2031
+ {
2032
+ call_id: callId,
2033
+ keepalive: true,
2034
+ },
2035
+ 0,
2036
+ true,
2037
+ );
2038
+ }
2039
+
1863
2040
  /**
1864
2041
  * Send a message in an existing chat session via SSE streaming.
1865
2042
  *
@@ -1884,7 +2061,7 @@ export class EkoDBClient {
1884
2061
  await this.refreshToken();
1885
2062
  token = await this.getToken();
1886
2063
  }
1887
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
2064
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1888
2065
 
1889
2066
  const response = await fetch(url, {
1890
2067
  method: "POST",
@@ -1979,7 +2156,7 @@ export class EkoDBClient {
1979
2156
  async getChatSession(sessionId: string): Promise<ChatSessionResponse> {
1980
2157
  return this.makeRequest<ChatSessionResponse>(
1981
2158
  "GET",
1982
- `/api/chat/${sessionId}`,
2159
+ `/api/chat/${encodeURIComponent(sessionId)}`,
1983
2160
  undefined,
1984
2161
  0,
1985
2162
  true, // Force JSON for chat operations
@@ -2022,8 +2199,8 @@ export class EkoDBClient {
2022
2199
 
2023
2200
  const queryString = params.toString();
2024
2201
  const path = queryString
2025
- ? `/api/chat/${sessionId}/messages?${queryString}`
2026
- : `/api/chat/${sessionId}/messages`;
2202
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
2203
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
2027
2204
  return this.makeRequest<GetMessagesResponse>(
2028
2205
  "GET",
2029
2206
  path,
@@ -2042,7 +2219,7 @@ export class EkoDBClient {
2042
2219
  ): Promise<ChatSessionResponse> {
2043
2220
  return this.makeRequest<ChatSessionResponse>(
2044
2221
  "PUT",
2045
- `/api/chat/${sessionId}`,
2222
+ `/api/chat/${encodeURIComponent(sessionId)}`,
2046
2223
  request,
2047
2224
  0,
2048
2225
  true, // Force JSON for chat operations
@@ -2070,7 +2247,7 @@ export class EkoDBClient {
2070
2247
  async deleteChatSession(sessionId: string): Promise<void> {
2071
2248
  await this.makeRequest<void>(
2072
2249
  "DELETE",
2073
- `/api/chat/${sessionId}`,
2250
+ `/api/chat/${encodeURIComponent(sessionId)}`,
2074
2251
  undefined,
2075
2252
  0,
2076
2253
  true, // Force JSON for chat operations
@@ -2086,7 +2263,7 @@ export class EkoDBClient {
2086
2263
  ): Promise<ChatResponse> {
2087
2264
  return this.makeRequest<ChatResponse>(
2088
2265
  "POST",
2089
- `/api/chat/${sessionId}/messages/${messageId}/regenerate`,
2266
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`,
2090
2267
  undefined,
2091
2268
  0,
2092
2269
  true, // Force JSON for chat operations
@@ -2103,7 +2280,7 @@ export class EkoDBClient {
2103
2280
  ): Promise<void> {
2104
2281
  await this.makeRequest<void>(
2105
2282
  "PUT",
2106
- `/api/chat/${sessionId}/messages/${messageId}`,
2283
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2107
2284
  { content },
2108
2285
  0,
2109
2286
  true, // Force JSON for chat operations
@@ -2116,7 +2293,7 @@ export class EkoDBClient {
2116
2293
  async deleteChatMessage(sessionId: string, messageId: string): Promise<void> {
2117
2294
  await this.makeRequest<void>(
2118
2295
  "DELETE",
2119
- `/api/chat/${sessionId}/messages/${messageId}`,
2296
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2120
2297
  undefined,
2121
2298
  0,
2122
2299
  true, // Force JSON for chat operations
@@ -2133,7 +2310,7 @@ export class EkoDBClient {
2133
2310
  ): Promise<void> {
2134
2311
  await this.makeRequest<void>(
2135
2312
  "PATCH",
2136
- `/api/chat/${sessionId}/messages/${messageId}/forgotten`,
2313
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`,
2137
2314
  { forgotten },
2138
2315
  0,
2139
2316
  true, // Force JSON for chat operations
@@ -2160,7 +2337,7 @@ export class EkoDBClient {
2160
2337
  }
2161
2338
  return this.makeRequest<CompactChatResponse>(
2162
2339
  "POST",
2163
- `/api/chat/${chatId}/compact`,
2340
+ `/api/chat/${encodeURIComponent(chatId)}/compact`,
2164
2341
  body,
2165
2342
  0,
2166
2343
  true, // Force JSON for chat operations
@@ -2235,7 +2412,7 @@ export class EkoDBClient {
2235
2412
  async getChatMessage(sessionId: string, messageId: string): Promise<Record> {
2236
2413
  return this.makeRequest<Record>(
2237
2414
  "GET",
2238
- `/api/chat/${sessionId}/messages/${messageId}`,
2415
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2239
2416
  undefined,
2240
2417
  0,
2241
2418
  true, // Force JSON for chat operations
@@ -2262,7 +2439,10 @@ export class EkoDBClient {
2262
2439
  * Get a function by ID
2263
2440
  */
2264
2441
  async getFunction(id: string): Promise<UserFunction> {
2265
- return this.makeRequest<UserFunction>("GET", `/api/functions/${id}`);
2442
+ return this.makeRequest<UserFunction>(
2443
+ "GET",
2444
+ `/api/functions/${encodeURIComponent(id)}`,
2445
+ );
2266
2446
  }
2267
2447
 
2268
2448
  /**
@@ -2277,14 +2457,21 @@ export class EkoDBClient {
2277
2457
  * Update an existing function by ID
2278
2458
  */
2279
2459
  async updateFunction(id: string, script: UserFunction): Promise<void> {
2280
- await this.makeRequest<void>("PUT", `/api/functions/${id}`, script);
2460
+ await this.makeRequest<void>(
2461
+ "PUT",
2462
+ `/api/functions/${encodeURIComponent(id)}`,
2463
+ script,
2464
+ );
2281
2465
  }
2282
2466
 
2283
2467
  /**
2284
2468
  * Delete a function by ID
2285
2469
  */
2286
2470
  async deleteFunction(id: string): Promise<void> {
2287
- await this.makeRequest<void>("DELETE", `/api/functions/${id}`);
2471
+ await this.makeRequest<void>(
2472
+ "DELETE",
2473
+ `/api/functions/${encodeURIComponent(id)}`,
2474
+ );
2288
2475
  }
2289
2476
 
2290
2477
  /**
@@ -2296,7 +2483,7 @@ export class EkoDBClient {
2296
2483
  ): Promise<FunctionResult> {
2297
2484
  return this.makeRequest<FunctionResult>(
2298
2485
  "POST",
2299
- `/api/functions/${idOrLabel}`,
2486
+ `/api/functions/${encodeURIComponent(idOrLabel)}`,
2300
2487
  params || {},
2301
2488
  );
2302
2489
  }
@@ -2543,7 +2730,7 @@ export class EkoDBClient {
2543
2730
  async goalStepStart(id: string, stepIndex: number): Promise<Record> {
2544
2731
  return this.makeRequest<Record>(
2545
2732
  "POST",
2546
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`,
2733
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`,
2547
2734
  undefined,
2548
2735
  0,
2549
2736
  true,
@@ -2558,7 +2745,7 @@ export class EkoDBClient {
2558
2745
  ): Promise<Record> {
2559
2746
  return this.makeRequest<Record>(
2560
2747
  "POST",
2561
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`,
2748
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`,
2562
2749
  data,
2563
2750
  0,
2564
2751
  true,
@@ -2573,7 +2760,7 @@ export class EkoDBClient {
2573
2760
  ): Promise<Record> {
2574
2761
  return this.makeRequest<Record>(
2575
2762
  "POST",
2576
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`,
2763
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`,
2577
2764
  data,
2578
2765
  0,
2579
2766
  true,
@@ -3446,6 +3633,13 @@ export class WebSocketClient {
3446
3633
  private ws: any = null;
3447
3634
  private dispatcherRunning = false;
3448
3635
  private schemaCache: SchemaCache | null = null;
3636
+ /**
3637
+ * Per-connection wire format, set by negotiateFormat() on every (re)connect:
3638
+ * true once the server has Welcomed msgpack, so frames are sent/received as
3639
+ * binary msgpack; false (JSON text) otherwise, including against an older
3640
+ * server that never Welcomes. Keeps the transport fully back-compatible.
3641
+ */
3642
+ private binary = false;
3449
3643
 
3450
3644
  // Reconnect config
3451
3645
  private autoReconnect: boolean;
@@ -3572,9 +3766,72 @@ export class WebSocketClient {
3572
3766
  this.ws.on("error", (err: Error) => reject(err));
3573
3767
  });
3574
3768
 
3769
+ // Negotiate the wire format before the dispatcher starts so the Welcome is
3770
+ // consumed here (not by routeMessage), and before any real frame is sent
3771
+ // (resubscribeAll runs only after this resolves).
3772
+ await this.negotiateFormat(this.ws);
3773
+
3575
3774
  this.spawnDispatcher();
3576
3775
  }
3577
3776
 
3777
+ /**
3778
+ * Additive capability handshake: offer msgpack and, if the server Welcomes
3779
+ * it, switch this connection to binary msgpack frames; otherwise stay on JSON
3780
+ * text. The Welcome (a text frame) is read with a one-shot listener and a
3781
+ * timeout so an older server that never answers — or answers with an Error —
3782
+ * simply leaves the connection on JSON. Best-effort and never throws: JSON
3783
+ * always works.
3784
+ */
3785
+ private async negotiateFormat(socket: any): Promise<void> {
3786
+ this.binary = false;
3787
+ const welcome = await new Promise<any | null>((resolve) => {
3788
+ const onMsg = (data: Buffer) => {
3789
+ clearTimeout(timer);
3790
+ try {
3791
+ resolve(JSON.parse(data.toString()));
3792
+ } catch {
3793
+ resolve(null);
3794
+ }
3795
+ };
3796
+ // Only caps the wait when no Welcome comes (a silent/old server); the
3797
+ // listener resolves immediately when it does arrive. 2s comfortably exceeds
3798
+ // the handshake round-trip even on high-latency links.
3799
+ const timer = setTimeout(() => {
3800
+ socket.off("message", onMsg);
3801
+ resolve(null);
3802
+ }, 2000);
3803
+ socket.once("message", onMsg);
3804
+ try {
3805
+ socket.send(
3806
+ JSON.stringify({
3807
+ type: "Hello",
3808
+ payload: { formats: ["msgpack", "json"] },
3809
+ }),
3810
+ );
3811
+ } catch {
3812
+ clearTimeout(timer);
3813
+ socket.off("message", onMsg);
3814
+ resolve(null);
3815
+ }
3816
+ });
3817
+ if (
3818
+ welcome &&
3819
+ welcome.type === "Welcome" &&
3820
+ welcome.payload?.format === "msgpack"
3821
+ ) {
3822
+ this.binary = true;
3823
+ }
3824
+ }
3825
+
3826
+ /**
3827
+ * Send a request object on the active socket using the negotiated format:
3828
+ * binary msgpack when the server Welcomed it, JSON text otherwise. The single
3829
+ * write point so every request honors the negotiated transport.
3830
+ */
3831
+ private sendFrame(obj: any): void {
3832
+ this.ws.send(this.binary ? encode(obj) : JSON.stringify(obj));
3833
+ }
3834
+
3578
3835
  private spawnDispatcher(): void {
3579
3836
  if (this.dispatcherRunning) return;
3580
3837
  this.dispatcherRunning = true;
@@ -3584,10 +3841,15 @@ export class WebSocketClient {
3584
3841
  // tear down the replacement connection.
3585
3842
  const socket = this.ws;
3586
3843
 
3587
- socket.on("message", (data: Buffer) => {
3844
+ socket.on("message", (data: Buffer, isBinary: boolean) => {
3588
3845
  if (this.ws !== socket) return;
3589
3846
  try {
3590
- const msg = JSON.parse(data.toString());
3847
+ // A binary frame is msgpack (the server only sends binary once it has
3848
+ // Welcomed msgpack); a text frame is JSON. Decode by frame type so the
3849
+ // routed value is identical regardless of negotiated transport.
3850
+ const msg = isBinary
3851
+ ? (decode(data) as any)
3852
+ : JSON.parse(data.toString());
3591
3853
  this.routeMessage(msg);
3592
3854
  } catch {
3593
3855
  // Ignore malformed messages
@@ -3897,7 +4159,7 @@ export class WebSocketClient {
3897
4159
 
3898
4160
  this.pendingRequests.set(messageId, { resolve, reject, timer });
3899
4161
  try {
3900
- this.ws.send(JSON.stringify(request));
4162
+ this.sendFrame(request);
3901
4163
  } catch (err) {
3902
4164
  this.pendingRequests.delete(messageId);
3903
4165
  if (timer) clearTimeout(timer);
@@ -3989,6 +4251,26 @@ export class WebSocketClient {
3989
4251
  if (stream && !stream.closed) {
3990
4252
  stream.close();
3991
4253
  }
4254
+ // Best-effort: tell the server to stop streaming this collection (the
4255
+ // server already handles an Unsubscribe frame). If the socket isn't open
4256
+ // the local teardown above suffices, since the server drops subscriptions
4257
+ // when the connection closes. A unique messageId is attached so the
4258
+ // server's Success ack carries a correlation id: it has no pending request
4259
+ // to match, so it is simply ignored — and because the id is present, the
4260
+ // single-pending fallback can't misroute it to an unrelated request.
4261
+ if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
4262
+ try {
4263
+ this.sendFrame({
4264
+ type: "Unsubscribe",
4265
+ messageId: this.genMessageId(),
4266
+ payload: { collection },
4267
+ });
4268
+ } catch {
4269
+ // Best-effort: the socket can close between the readyState check and the
4270
+ // send. Local teardown already happened, so swallow the failure rather
4271
+ // than throw out of a void teardown call.
4272
+ }
4273
+ }
3992
4274
  }
3993
4275
 
3994
4276
  /**
@@ -4026,7 +4308,7 @@ export class WebSocketClient {
4026
4308
  },
4027
4309
  };
4028
4310
 
4029
- this.ws.send(JSON.stringify(request));
4311
+ this.sendFrame(request);
4030
4312
  return stream;
4031
4313
  }
4032
4314
 
@@ -4052,7 +4334,7 @@ export class WebSocketClient {
4052
4334
  resolve: () => resolve(),
4053
4335
  reject: (err) => reject(err),
4054
4336
  };
4055
- this.ws.send(JSON.stringify(request));
4337
+ this.sendFrame(request);
4056
4338
  });
4057
4339
  }
4058
4340
 
@@ -4079,7 +4361,27 @@ export class WebSocketClient {
4079
4361
  },
4080
4362
  };
4081
4363
 
4082
- this.ws.send(JSON.stringify(request));
4364
+ this.sendFrame(request);
4365
+ }
4366
+
4367
+ /**
4368
+ * Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
4369
+ * stop generating tokens for the given chat.
4370
+ */
4371
+ async cancelChat(chatId: string): Promise<void> {
4372
+ await this.ensureConnected();
4373
+
4374
+ // Attach a unique messageId (same generator as unsubscribe). Any Success ack
4375
+ // from the server then carries a correlation id: it has no pending request to
4376
+ // match, so it is ignored — and because the id is present, the dispatcher's
4377
+ // single-pending fallback can't misroute the ack to an unrelated request.
4378
+ const request = {
4379
+ type: "CancelChat",
4380
+ messageId: this.genMessageId(),
4381
+ payload: { chat_id: chatId },
4382
+ };
4383
+
4384
+ this.sendFrame(request);
4083
4385
  }
4084
4386
 
4085
4387
  /**