@ekodb/ekodb-client 0.20.0 → 0.21.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,
@@ -1884,7 +2038,7 @@ export class EkoDBClient {
1884
2038
  await this.refreshToken();
1885
2039
  token = await this.getToken();
1886
2040
  }
1887
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
2041
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1888
2042
 
1889
2043
  const response = await fetch(url, {
1890
2044
  method: "POST",
@@ -1979,7 +2133,7 @@ export class EkoDBClient {
1979
2133
  async getChatSession(sessionId: string): Promise<ChatSessionResponse> {
1980
2134
  return this.makeRequest<ChatSessionResponse>(
1981
2135
  "GET",
1982
- `/api/chat/${sessionId}`,
2136
+ `/api/chat/${encodeURIComponent(sessionId)}`,
1983
2137
  undefined,
1984
2138
  0,
1985
2139
  true, // Force JSON for chat operations
@@ -2022,8 +2176,8 @@ export class EkoDBClient {
2022
2176
 
2023
2177
  const queryString = params.toString();
2024
2178
  const path = queryString
2025
- ? `/api/chat/${sessionId}/messages?${queryString}`
2026
- : `/api/chat/${sessionId}/messages`;
2179
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
2180
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
2027
2181
  return this.makeRequest<GetMessagesResponse>(
2028
2182
  "GET",
2029
2183
  path,
@@ -2042,7 +2196,7 @@ export class EkoDBClient {
2042
2196
  ): Promise<ChatSessionResponse> {
2043
2197
  return this.makeRequest<ChatSessionResponse>(
2044
2198
  "PUT",
2045
- `/api/chat/${sessionId}`,
2199
+ `/api/chat/${encodeURIComponent(sessionId)}`,
2046
2200
  request,
2047
2201
  0,
2048
2202
  true, // Force JSON for chat operations
@@ -2070,7 +2224,7 @@ export class EkoDBClient {
2070
2224
  async deleteChatSession(sessionId: string): Promise<void> {
2071
2225
  await this.makeRequest<void>(
2072
2226
  "DELETE",
2073
- `/api/chat/${sessionId}`,
2227
+ `/api/chat/${encodeURIComponent(sessionId)}`,
2074
2228
  undefined,
2075
2229
  0,
2076
2230
  true, // Force JSON for chat operations
@@ -2086,7 +2240,7 @@ export class EkoDBClient {
2086
2240
  ): Promise<ChatResponse> {
2087
2241
  return this.makeRequest<ChatResponse>(
2088
2242
  "POST",
2089
- `/api/chat/${sessionId}/messages/${messageId}/regenerate`,
2243
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`,
2090
2244
  undefined,
2091
2245
  0,
2092
2246
  true, // Force JSON for chat operations
@@ -2103,7 +2257,7 @@ export class EkoDBClient {
2103
2257
  ): Promise<void> {
2104
2258
  await this.makeRequest<void>(
2105
2259
  "PUT",
2106
- `/api/chat/${sessionId}/messages/${messageId}`,
2260
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2107
2261
  { content },
2108
2262
  0,
2109
2263
  true, // Force JSON for chat operations
@@ -2116,7 +2270,7 @@ export class EkoDBClient {
2116
2270
  async deleteChatMessage(sessionId: string, messageId: string): Promise<void> {
2117
2271
  await this.makeRequest<void>(
2118
2272
  "DELETE",
2119
- `/api/chat/${sessionId}/messages/${messageId}`,
2273
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2120
2274
  undefined,
2121
2275
  0,
2122
2276
  true, // Force JSON for chat operations
@@ -2133,7 +2287,7 @@ export class EkoDBClient {
2133
2287
  ): Promise<void> {
2134
2288
  await this.makeRequest<void>(
2135
2289
  "PATCH",
2136
- `/api/chat/${sessionId}/messages/${messageId}/forgotten`,
2290
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`,
2137
2291
  { forgotten },
2138
2292
  0,
2139
2293
  true, // Force JSON for chat operations
@@ -2160,7 +2314,7 @@ export class EkoDBClient {
2160
2314
  }
2161
2315
  return this.makeRequest<CompactChatResponse>(
2162
2316
  "POST",
2163
- `/api/chat/${chatId}/compact`,
2317
+ `/api/chat/${encodeURIComponent(chatId)}/compact`,
2164
2318
  body,
2165
2319
  0,
2166
2320
  true, // Force JSON for chat operations
@@ -2235,7 +2389,7 @@ export class EkoDBClient {
2235
2389
  async getChatMessage(sessionId: string, messageId: string): Promise<Record> {
2236
2390
  return this.makeRequest<Record>(
2237
2391
  "GET",
2238
- `/api/chat/${sessionId}/messages/${messageId}`,
2392
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2239
2393
  undefined,
2240
2394
  0,
2241
2395
  true, // Force JSON for chat operations
@@ -2262,7 +2416,10 @@ export class EkoDBClient {
2262
2416
  * Get a function by ID
2263
2417
  */
2264
2418
  async getFunction(id: string): Promise<UserFunction> {
2265
- return this.makeRequest<UserFunction>("GET", `/api/functions/${id}`);
2419
+ return this.makeRequest<UserFunction>(
2420
+ "GET",
2421
+ `/api/functions/${encodeURIComponent(id)}`,
2422
+ );
2266
2423
  }
2267
2424
 
2268
2425
  /**
@@ -2277,14 +2434,21 @@ export class EkoDBClient {
2277
2434
  * Update an existing function by ID
2278
2435
  */
2279
2436
  async updateFunction(id: string, script: UserFunction): Promise<void> {
2280
- await this.makeRequest<void>("PUT", `/api/functions/${id}`, script);
2437
+ await this.makeRequest<void>(
2438
+ "PUT",
2439
+ `/api/functions/${encodeURIComponent(id)}`,
2440
+ script,
2441
+ );
2281
2442
  }
2282
2443
 
2283
2444
  /**
2284
2445
  * Delete a function by ID
2285
2446
  */
2286
2447
  async deleteFunction(id: string): Promise<void> {
2287
- await this.makeRequest<void>("DELETE", `/api/functions/${id}`);
2448
+ await this.makeRequest<void>(
2449
+ "DELETE",
2450
+ `/api/functions/${encodeURIComponent(id)}`,
2451
+ );
2288
2452
  }
2289
2453
 
2290
2454
  /**
@@ -2296,7 +2460,7 @@ export class EkoDBClient {
2296
2460
  ): Promise<FunctionResult> {
2297
2461
  return this.makeRequest<FunctionResult>(
2298
2462
  "POST",
2299
- `/api/functions/${idOrLabel}`,
2463
+ `/api/functions/${encodeURIComponent(idOrLabel)}`,
2300
2464
  params || {},
2301
2465
  );
2302
2466
  }
@@ -2543,7 +2707,7 @@ export class EkoDBClient {
2543
2707
  async goalStepStart(id: string, stepIndex: number): Promise<Record> {
2544
2708
  return this.makeRequest<Record>(
2545
2709
  "POST",
2546
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`,
2710
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`,
2547
2711
  undefined,
2548
2712
  0,
2549
2713
  true,
@@ -2558,7 +2722,7 @@ export class EkoDBClient {
2558
2722
  ): Promise<Record> {
2559
2723
  return this.makeRequest<Record>(
2560
2724
  "POST",
2561
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`,
2725
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`,
2562
2726
  data,
2563
2727
  0,
2564
2728
  true,
@@ -2573,7 +2737,7 @@ export class EkoDBClient {
2573
2737
  ): Promise<Record> {
2574
2738
  return this.makeRequest<Record>(
2575
2739
  "POST",
2576
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`,
2740
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`,
2577
2741
  data,
2578
2742
  0,
2579
2743
  true,
@@ -3446,6 +3610,13 @@ export class WebSocketClient {
3446
3610
  private ws: any = null;
3447
3611
  private dispatcherRunning = false;
3448
3612
  private schemaCache: SchemaCache | null = null;
3613
+ /**
3614
+ * Per-connection wire format, set by negotiateFormat() on every (re)connect:
3615
+ * true once the server has Welcomed msgpack, so frames are sent/received as
3616
+ * binary msgpack; false (JSON text) otherwise, including against an older
3617
+ * server that never Welcomes. Keeps the transport fully back-compatible.
3618
+ */
3619
+ private binary = false;
3449
3620
 
3450
3621
  // Reconnect config
3451
3622
  private autoReconnect: boolean;
@@ -3572,9 +3743,72 @@ export class WebSocketClient {
3572
3743
  this.ws.on("error", (err: Error) => reject(err));
3573
3744
  });
3574
3745
 
3746
+ // Negotiate the wire format before the dispatcher starts so the Welcome is
3747
+ // consumed here (not by routeMessage), and before any real frame is sent
3748
+ // (resubscribeAll runs only after this resolves).
3749
+ await this.negotiateFormat(this.ws);
3750
+
3575
3751
  this.spawnDispatcher();
3576
3752
  }
3577
3753
 
3754
+ /**
3755
+ * Additive capability handshake: offer msgpack and, if the server Welcomes
3756
+ * it, switch this connection to binary msgpack frames; otherwise stay on JSON
3757
+ * text. The Welcome (a text frame) is read with a one-shot listener and a
3758
+ * timeout so an older server that never answers — or answers with an Error —
3759
+ * simply leaves the connection on JSON. Best-effort and never throws: JSON
3760
+ * always works.
3761
+ */
3762
+ private async negotiateFormat(socket: any): Promise<void> {
3763
+ this.binary = false;
3764
+ const welcome = await new Promise<any | null>((resolve) => {
3765
+ const onMsg = (data: Buffer) => {
3766
+ clearTimeout(timer);
3767
+ try {
3768
+ resolve(JSON.parse(data.toString()));
3769
+ } catch {
3770
+ resolve(null);
3771
+ }
3772
+ };
3773
+ // Only caps the wait when no Welcome comes (a silent/old server); the
3774
+ // listener resolves immediately when it does arrive. 2s comfortably exceeds
3775
+ // the handshake round-trip even on high-latency links.
3776
+ const timer = setTimeout(() => {
3777
+ socket.off("message", onMsg);
3778
+ resolve(null);
3779
+ }, 2000);
3780
+ socket.once("message", onMsg);
3781
+ try {
3782
+ socket.send(
3783
+ JSON.stringify({
3784
+ type: "Hello",
3785
+ payload: { formats: ["msgpack", "json"] },
3786
+ }),
3787
+ );
3788
+ } catch {
3789
+ clearTimeout(timer);
3790
+ socket.off("message", onMsg);
3791
+ resolve(null);
3792
+ }
3793
+ });
3794
+ if (
3795
+ welcome &&
3796
+ welcome.type === "Welcome" &&
3797
+ welcome.payload?.format === "msgpack"
3798
+ ) {
3799
+ this.binary = true;
3800
+ }
3801
+ }
3802
+
3803
+ /**
3804
+ * Send a request object on the active socket using the negotiated format:
3805
+ * binary msgpack when the server Welcomed it, JSON text otherwise. The single
3806
+ * write point so every request honors the negotiated transport.
3807
+ */
3808
+ private sendFrame(obj: any): void {
3809
+ this.ws.send(this.binary ? encode(obj) : JSON.stringify(obj));
3810
+ }
3811
+
3578
3812
  private spawnDispatcher(): void {
3579
3813
  if (this.dispatcherRunning) return;
3580
3814
  this.dispatcherRunning = true;
@@ -3584,10 +3818,15 @@ export class WebSocketClient {
3584
3818
  // tear down the replacement connection.
3585
3819
  const socket = this.ws;
3586
3820
 
3587
- socket.on("message", (data: Buffer) => {
3821
+ socket.on("message", (data: Buffer, isBinary: boolean) => {
3588
3822
  if (this.ws !== socket) return;
3589
3823
  try {
3590
- const msg = JSON.parse(data.toString());
3824
+ // A binary frame is msgpack (the server only sends binary once it has
3825
+ // Welcomed msgpack); a text frame is JSON. Decode by frame type so the
3826
+ // routed value is identical regardless of negotiated transport.
3827
+ const msg = isBinary
3828
+ ? (decode(data) as any)
3829
+ : JSON.parse(data.toString());
3591
3830
  this.routeMessage(msg);
3592
3831
  } catch {
3593
3832
  // Ignore malformed messages
@@ -3897,7 +4136,7 @@ export class WebSocketClient {
3897
4136
 
3898
4137
  this.pendingRequests.set(messageId, { resolve, reject, timer });
3899
4138
  try {
3900
- this.ws.send(JSON.stringify(request));
4139
+ this.sendFrame(request);
3901
4140
  } catch (err) {
3902
4141
  this.pendingRequests.delete(messageId);
3903
4142
  if (timer) clearTimeout(timer);
@@ -3989,6 +4228,26 @@ export class WebSocketClient {
3989
4228
  if (stream && !stream.closed) {
3990
4229
  stream.close();
3991
4230
  }
4231
+ // Best-effort: tell the server to stop streaming this collection (the
4232
+ // server already handles an Unsubscribe frame). If the socket isn't open
4233
+ // the local teardown above suffices, since the server drops subscriptions
4234
+ // when the connection closes. A unique messageId is attached so the
4235
+ // server's Success ack carries a correlation id: it has no pending request
4236
+ // to match, so it is simply ignored — and because the id is present, the
4237
+ // single-pending fallback can't misroute it to an unrelated request.
4238
+ if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
4239
+ try {
4240
+ this.sendFrame({
4241
+ type: "Unsubscribe",
4242
+ messageId: this.genMessageId(),
4243
+ payload: { collection },
4244
+ });
4245
+ } catch {
4246
+ // Best-effort: the socket can close between the readyState check and the
4247
+ // send. Local teardown already happened, so swallow the failure rather
4248
+ // than throw out of a void teardown call.
4249
+ }
4250
+ }
3992
4251
  }
3993
4252
 
3994
4253
  /**
@@ -4026,7 +4285,7 @@ export class WebSocketClient {
4026
4285
  },
4027
4286
  };
4028
4287
 
4029
- this.ws.send(JSON.stringify(request));
4288
+ this.sendFrame(request);
4030
4289
  return stream;
4031
4290
  }
4032
4291
 
@@ -4052,7 +4311,7 @@ export class WebSocketClient {
4052
4311
  resolve: () => resolve(),
4053
4312
  reject: (err) => reject(err),
4054
4313
  };
4055
- this.ws.send(JSON.stringify(request));
4314
+ this.sendFrame(request);
4056
4315
  });
4057
4316
  }
4058
4317
 
@@ -4079,7 +4338,27 @@ export class WebSocketClient {
4079
4338
  },
4080
4339
  };
4081
4340
 
4082
- this.ws.send(JSON.stringify(request));
4341
+ this.sendFrame(request);
4342
+ }
4343
+
4344
+ /**
4345
+ * Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
4346
+ * stop generating tokens for the given chat.
4347
+ */
4348
+ async cancelChat(chatId: string): Promise<void> {
4349
+ await this.ensureConnected();
4350
+
4351
+ // Attach a unique messageId (same generator as unsubscribe). Any Success ack
4352
+ // from the server then carries a correlation id: it has no pending request to
4353
+ // match, so it is ignored — and because the id is present, the dispatcher's
4354
+ // single-pending fallback can't misroute the ack to an unrelated request.
4355
+ const request = {
4356
+ type: "CancelChat",
4357
+ messageId: this.genMessageId(),
4358
+ payload: { chat_id: chatId },
4359
+ };
4360
+
4361
+ this.sendFrame(request);
4083
4362
  }
4084
4363
 
4085
4364
  /**