@ekodb/ekodb-client 0.19.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 {
@@ -379,6 +397,20 @@ export interface FunctionStageConfig {
379
397
  [key: string]: any;
380
398
  }
381
399
 
400
+ /**
401
+ * Strip trailing slashes from a base URL so path concatenation
402
+ * (`${base}/api/...`) never yields a double-slash path. Uses a linear scan
403
+ * rather than a regex like `/\/+$/`, which CodeQL flags as polynomial-time
404
+ * backtracking on caller-supplied input.
405
+ */
406
+ function stripTrailingSlashes(url: string): string {
407
+ let end = url.length;
408
+ while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
409
+ end--;
410
+ }
411
+ return end === url.length ? url : url.slice(0, end);
412
+ }
413
+
382
414
  export class EkoDBClient {
383
415
  private baseURL: string;
384
416
  private apiKey: string;
@@ -392,13 +424,15 @@ export class EkoDBClient {
392
424
  constructor(config: string | ClientConfig, apiKey?: string) {
393
425
  // Support both old (baseURL, apiKey) and new (config object) signatures
394
426
  if (typeof config === "string") {
395
- this.baseURL = config;
427
+ // Strip trailing slashes so `${baseURL}/api/...` never produces a
428
+ // double-slash path (some servers/proxies reject `//api/...`).
429
+ this.baseURL = stripTrailingSlashes(config);
396
430
  this.apiKey = apiKey!;
397
431
  this.shouldRetry = true;
398
432
  this.maxRetries = 3;
399
433
  this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
400
434
  } else {
401
- this.baseURL = config.baseURL;
435
+ this.baseURL = stripTrailingSlashes(config.baseURL);
402
436
  this.apiKey = config.apiKey;
403
437
  this.shouldRetry = config.shouldRetry ?? true;
404
438
  this.maxRetries = config.maxRetries ?? 3;
@@ -555,6 +589,41 @@ export class EkoDBClient {
555
589
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
556
590
  }
557
591
 
592
+ /**
593
+ * Parse a `Retry-After` header into a non-negative delay in seconds.
594
+ *
595
+ * Per RFC 9110 the value is either delay-seconds (an integer) or an
596
+ * HTTP-date. Anything that doesn't resolve to a finite, non-negative number
597
+ * (missing header, garbage, a past date) falls back to `defaultSecs`.
598
+ */
599
+ private parseRetryAfter(header: string | null, defaultSecs = 60): number {
600
+ if (!header) return defaultSecs;
601
+
602
+ // delay-seconds form: a bare integer.
603
+ const secs = Number(header.trim());
604
+ if (Number.isFinite(secs)) return Math.max(0, secs);
605
+
606
+ // HTTP-date form: compute the delay from now.
607
+ const dateMs = Date.parse(header);
608
+ if (Number.isFinite(dateMs)) {
609
+ return Math.max(0, (dateMs - Date.now()) / 1000);
610
+ }
611
+
612
+ return defaultSecs;
613
+ }
614
+
615
+ /**
616
+ * Backoff delay (in seconds) for a 0-indexed retry attempt: a capped
617
+ * exponential schedule (0.2s → 5s) with full jitter, so concurrent clients
618
+ * don't retry in lockstep. Returns a value in [d/2, d].
619
+ */
620
+ private backoffSeconds(attempt: number): number {
621
+ const base = 0.2;
622
+ const max = 5;
623
+ const d = Math.min(base * Math.pow(2, Math.max(0, attempt)), max);
624
+ return d / 2 + Math.random() * (d / 2);
625
+ }
626
+
558
627
  /**
559
628
  * Helper to determine if a path should use JSON
560
629
  * Only CRUD operations (insert/update/delete/batch) use MessagePack
@@ -640,14 +709,16 @@ export class EkoDBClient {
640
709
 
641
710
  // Handle rate limiting (429)
642
711
  if (response.status === 429) {
643
- const retryAfter = parseInt(
644
- response.headers.get("retry-after") || "60",
645
- 10,
712
+ const retryAfter = this.parseRetryAfter(
713
+ response.headers.get("retry-after"),
646
714
  );
647
715
 
648
716
  if (this.shouldRetry && attempt < this.maxRetries) {
649
- console.log(`Rate limited. Retrying after ${retryAfter} seconds...`);
650
- await this.sleep(retryAfter);
717
+ // Honor the server's Retry-After, but cap it so a hostile/large value
718
+ // can't pin the client for minutes.
719
+ const wait = Math.min(retryAfter, 60);
720
+ console.log(`Rate limited. Retrying after ${wait} seconds...`);
721
+ await this.sleep(wait);
651
722
  return this.makeRequest<T>(
652
723
  method,
653
724
  path,
@@ -674,9 +745,9 @@ export class EkoDBClient {
674
745
  this.shouldRetry &&
675
746
  attempt < this.maxRetries
676
747
  ) {
677
- const retryDelay = 10;
748
+ const retryDelay = this.backoffSeconds(attempt);
678
749
  console.log(
679
- `Service unavailable. Retrying after ${retryDelay} seconds...`,
750
+ `Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`,
680
751
  );
681
752
  await this.sleep(retryDelay);
682
753
  return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
@@ -692,8 +763,10 @@ export class EkoDBClient {
692
763
  this.shouldRetry &&
693
764
  attempt < this.maxRetries
694
765
  ) {
695
- const retryDelay = 3;
696
- console.log(`Network error. Retrying after ${retryDelay} seconds...`);
766
+ const retryDelay = this.backoffSeconds(attempt);
767
+ console.log(
768
+ `Network error. Retrying after ${retryDelay.toFixed(2)}s...`,
769
+ );
697
770
  await this.sleep(retryDelay);
698
771
  return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
699
772
  }
@@ -727,8 +800,8 @@ export class EkoDBClient {
727
800
  }
728
801
 
729
802
  const url = params.toString()
730
- ? `/api/insert/${collection}?${params.toString()}`
731
- : `/api/insert/${collection}`;
803
+ ? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
804
+ : `/api/insert/${encodeURIComponent(collection)}`;
732
805
 
733
806
  return this.makeRequest<Record>("POST", url, data);
734
807
  }
@@ -758,20 +831,64 @@ export class EkoDBClient {
758
831
  async find(
759
832
  collection: string,
760
833
  query: Query | QueryBuilder = {},
834
+ options?: { bypassRipple?: boolean; transactionId?: string },
761
835
  ): Promise<Record[]> {
762
836
  const queryObj = query instanceof QueryBuilder ? query.build() : query;
763
- return this.makeRequest<Record[]>(
764
- "POST",
765
- `/api/find/${collection}`,
766
- queryObj,
767
- );
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);
768
861
  }
769
862
 
770
863
  /**
771
- * 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}.
772
867
  */
773
- async findById(collection: string, id: string): Promise<Record> {
774
- 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);
775
892
  }
776
893
 
777
894
  /**
@@ -780,12 +897,14 @@ export class EkoDBClient {
780
897
  * @param id - Document ID
781
898
  * @param selectFields - Fields to include in the result
782
899
  * @param excludeFields - Fields to exclude from the result
900
+ * @param transactionId - Read within a transaction (read-your-writes)
783
901
  */
784
902
  async findByIdWithProjection(
785
903
  collection: string,
786
904
  id: string,
787
905
  selectFields?: string[],
788
906
  excludeFields?: string[],
907
+ transactionId?: string,
789
908
  ): Promise<Record> {
790
909
  const params = new URLSearchParams();
791
910
  if (selectFields?.length) {
@@ -794,9 +913,12 @@ export class EkoDBClient {
794
913
  if (excludeFields?.length) {
795
914
  params.append("exclude_fields", excludeFields.join(","));
796
915
  }
916
+ if (transactionId) {
917
+ params.append("transaction_id", transactionId);
918
+ }
797
919
  const url = params.toString()
798
- ? `/api/find/${collection}/${id}?${params.toString()}`
799
- : `/api/find/${collection}/${id}`;
920
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
921
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
800
922
  return this.makeRequest<Record>("GET", url);
801
923
  }
802
924
 
@@ -822,8 +944,8 @@ export class EkoDBClient {
822
944
  }
823
945
 
824
946
  const url = params.toString()
825
- ? `/api/update/${collection}/${id}?${params.toString()}`
826
- : `/api/update/${collection}/${id}`;
947
+ ? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
948
+ : `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
827
949
 
828
950
  return this.makeRequest<Record>("PUT", url, record);
829
951
  }
@@ -848,7 +970,7 @@ export class EkoDBClient {
848
970
  field: string,
849
971
  value?: any,
850
972
  ): Promise<Record> {
851
- const url = `/api/update/${collection}/${id}/action/${action}`;
973
+ const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
852
974
  return this.makeRequest<Record>("PUT", url, {
853
975
  field,
854
976
  value: value ?? null,
@@ -870,7 +992,7 @@ export class EkoDBClient {
870
992
  id: string,
871
993
  actions: [string, string, any][],
872
994
  ): Promise<Record> {
873
- const url = `/api/update/sequence/${collection}/${id}`;
995
+ const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
874
996
  return this.makeRequest<Record>("PUT", url, actions);
875
997
  }
876
998
 
@@ -894,8 +1016,8 @@ export class EkoDBClient {
894
1016
  }
895
1017
 
896
1018
  const url = params.toString()
897
- ? `/api/delete/${collection}/${id}?${params.toString()}`
898
- : `/api/delete/${collection}/${id}`;
1019
+ ? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
1020
+ : `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
899
1021
 
900
1022
  await this.makeRequest<void>("DELETE", url);
901
1023
  }
@@ -921,8 +1043,8 @@ export class EkoDBClient {
921
1043
 
922
1044
  const inserts = records.map((data) => ({ data }));
923
1045
  const url = params.toString()
924
- ? `/api/batch/insert/${collection}?${params.toString()}`
925
- : `/api/batch/insert/${collection}`;
1046
+ ? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
1047
+ : `/api/batch/insert/${encodeURIComponent(collection)}`;
926
1048
 
927
1049
  return this.makeRequest<BatchOperationResult>("POST", url, { inserts });
928
1050
  }
@@ -941,7 +1063,7 @@ export class EkoDBClient {
941
1063
  }));
942
1064
  return this.makeRequest<BatchOperationResult>(
943
1065
  "PUT",
944
- `/api/batch/update/${collection}`,
1066
+ `/api/batch/update/${encodeURIComponent(collection)}`,
945
1067
  { updates: formattedUpdates },
946
1068
  );
947
1069
  }
@@ -960,7 +1082,7 @@ export class EkoDBClient {
960
1082
  }));
961
1083
  return this.makeRequest<BatchOperationResult>(
962
1084
  "DELETE",
963
- `/api/batch/delete/${collection}`,
1085
+ `/api/batch/delete/${encodeURIComponent(collection)}`,
964
1086
  { deletes },
965
1087
  );
966
1088
  }
@@ -1012,6 +1134,19 @@ export class EkoDBClient {
1012
1134
  );
1013
1135
  }
1014
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
+
1015
1150
  /**
1016
1151
  * Batch get multiple keys
1017
1152
  * @param keys - Array of keys to retrieve
@@ -1116,7 +1251,18 @@ export class EkoDBClient {
1116
1251
  // ============================================================================
1117
1252
 
1118
1253
  /**
1119
- * 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
+ *
1120
1266
  * @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
1121
1267
  * @returns Transaction ID
1122
1268
  */
@@ -1165,7 +1311,7 @@ export class EkoDBClient {
1165
1311
  }
1166
1312
 
1167
1313
  /**
1168
- * Rollback a transaction
1314
+ * Rollback a transaction (discards all staged writes; nothing was applied).
1169
1315
  * @param transactionId - The transaction ID to rollback
1170
1316
  */
1171
1317
  async rollbackTransaction(transactionId: string): Promise<void> {
@@ -1178,6 +1324,49 @@ export class EkoDBClient {
1178
1324
  );
1179
1325
  }
1180
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
+
1181
1370
  // ============================================================================
1182
1371
  // Convenience Methods
1183
1372
  // ============================================================================
@@ -1324,7 +1513,7 @@ export class EkoDBClient {
1324
1513
  ): Promise<Record[]> {
1325
1514
  // Page 1 = skip 0, Page 2 = skip pageSize, etc.
1326
1515
  const skip = page > 0 ? (page - 1) * pageSize : 0;
1327
- const query: QueryBuilderQuery = {
1516
+ const query: Query = {
1328
1517
  limit: pageSize,
1329
1518
  skip: skip,
1330
1519
  };
@@ -1345,13 +1534,27 @@ export class EkoDBClient {
1345
1534
  return result.collections;
1346
1535
  }
1347
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
+
1348
1551
  /**
1349
1552
  * Delete a collection
1350
1553
  */
1351
1554
  async deleteCollection(collection: string): Promise<void> {
1352
1555
  await this.makeRequest<void>(
1353
1556
  "DELETE",
1354
- `/api/collections/${collection}`,
1557
+ `/api/collections/${encodeURIComponent(collection)}`,
1355
1558
  undefined,
1356
1559
  0,
1357
1560
  true, // Force JSON for metadata operations
@@ -1369,7 +1572,7 @@ export class EkoDBClient {
1369
1572
  async restoreRecord(collection: string, id: string): Promise<boolean> {
1370
1573
  const result = await this.makeRequest<{ status: string }>(
1371
1574
  "POST",
1372
- `/api/trash/${collection}/${id}`,
1575
+ `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
1373
1576
  undefined,
1374
1577
  0,
1375
1578
  true,
@@ -1391,7 +1594,13 @@ export class EkoDBClient {
1391
1594
  status: string;
1392
1595
  collection: string;
1393
1596
  records_restored: number;
1394
- }>("POST", `/api/trash/${collection}`, undefined, 0, true);
1597
+ }>(
1598
+ "POST",
1599
+ `/api/trash/${encodeURIComponent(collection)}`,
1600
+ undefined,
1601
+ 0,
1602
+ true,
1603
+ );
1395
1604
  return { recordsRestored: result.records_restored };
1396
1605
  }
1397
1606
 
@@ -1418,7 +1627,7 @@ export class EkoDBClient {
1418
1627
  const schemaObj = schema instanceof SchemaBuilder ? schema.build() : schema;
1419
1628
  await this.makeRequest<void>(
1420
1629
  "POST",
1421
- `/api/collections/${collection}`,
1630
+ `/api/collections/${encodeURIComponent(collection)}`,
1422
1631
  schemaObj,
1423
1632
  0,
1424
1633
  true, // Force JSON for metadata operations
@@ -1434,7 +1643,7 @@ export class EkoDBClient {
1434
1643
  async getCollection(collection: string): Promise<CollectionMetadata> {
1435
1644
  return this.makeRequest<CollectionMetadata>(
1436
1645
  "GET",
1437
- `/api/collections/${collection}`,
1646
+ `/api/collections/${encodeURIComponent(collection)}`,
1438
1647
  undefined,
1439
1648
  0,
1440
1649
  true, // Force JSON for metadata operations
@@ -1492,7 +1701,7 @@ export class EkoDBClient {
1492
1701
  // Ensure all parameters from SearchQuery are sent to server
1493
1702
  return this.makeRequest<SearchResponse>(
1494
1703
  "POST",
1495
- `/api/search/${collection}`,
1704
+ `/api/search/${encodeURIComponent(collection)}`,
1496
1705
  query,
1497
1706
  0,
1498
1707
  true, // Force JSON for search operations
@@ -1537,7 +1746,7 @@ export class EkoDBClient {
1537
1746
 
1538
1747
  return this.makeRequest<DistinctValuesResponse>(
1539
1748
  "POST",
1540
- `/api/distinct/${collection}/${field}`,
1749
+ `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`,
1541
1750
  body,
1542
1751
  0,
1543
1752
  true, // Force JSON
@@ -1773,7 +1982,7 @@ export class EkoDBClient {
1773
1982
  ): Promise<ChatResponse> {
1774
1983
  return this.makeRequest<ChatResponse>(
1775
1984
  "POST",
1776
- `/api/chat/${sessionId}/messages`,
1985
+ `/api/chat/${encodeURIComponent(sessionId)}/messages`,
1777
1986
  request,
1778
1987
  0,
1779
1988
  true, // Force JSON for chat operations
@@ -1793,7 +2002,7 @@ export class EkoDBClient {
1793
2002
  ): Promise<void> {
1794
2003
  await this.makeRequest(
1795
2004
  "POST",
1796
- `/api/chat/${chatId}/tool-result`,
2005
+ `/api/chat/${encodeURIComponent(chatId)}/tool-result`,
1797
2006
  {
1798
2007
  call_id: callId,
1799
2008
  success,
@@ -1824,12 +2033,12 @@ export class EkoDBClient {
1824
2033
 
1825
2034
  (async () => {
1826
2035
  try {
1827
- let token = this.getToken();
2036
+ let token = await this.getToken();
1828
2037
  if (!token) {
1829
2038
  await this.refreshToken();
1830
- token = this.getToken();
2039
+ token = await this.getToken();
1831
2040
  }
1832
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
2041
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1833
2042
 
1834
2043
  const response = await fetch(url, {
1835
2044
  method: "POST",
@@ -1851,11 +2060,10 @@ export class EkoDBClient {
1851
2060
  return;
1852
2061
  }
1853
2062
 
1854
- const body = await response.text();
1855
- for (const line of body.split("\n")) {
1856
- if (!line.startsWith("data:")) continue;
2063
+ const emitLine = (line: string) => {
2064
+ if (!line.startsWith("data:")) return;
1857
2065
  const dataStr = line.slice(5).trim();
1858
- if (!dataStr) continue;
2066
+ if (!dataStr) return;
1859
2067
  try {
1860
2068
  const eventData = JSON.parse(dataStr);
1861
2069
  if (eventData.error) {
@@ -1881,6 +2089,30 @@ export class EkoDBClient {
1881
2089
  } catch {
1882
2090
  // skip malformed SSE data
1883
2091
  }
2092
+ };
2093
+
2094
+ const reader = response.body?.getReader?.();
2095
+ if (reader) {
2096
+ // True incremental streaming: decode and emit each SSE line as soon as
2097
+ // it arrives, rather than buffering the entire response body first.
2098
+ const decoder = new TextDecoder();
2099
+ let buffer = "";
2100
+ for (;;) {
2101
+ const { done, value } = await reader.read();
2102
+ if (done) break;
2103
+ buffer += decoder.decode(value, { stream: true });
2104
+ let nl: number;
2105
+ while ((nl = buffer.indexOf("\n")) >= 0) {
2106
+ emitLine(buffer.slice(0, nl));
2107
+ buffer = buffer.slice(nl + 1);
2108
+ }
2109
+ }
2110
+ buffer += decoder.decode();
2111
+ if (buffer) emitLine(buffer);
2112
+ } else {
2113
+ // Fallback for environments/tests without a readable body stream.
2114
+ const body = await response.text();
2115
+ for (const line of body.split("\n")) emitLine(line);
1884
2116
  }
1885
2117
  stream.close();
1886
2118
  } catch (err: any) {
@@ -1901,7 +2133,7 @@ export class EkoDBClient {
1901
2133
  async getChatSession(sessionId: string): Promise<ChatSessionResponse> {
1902
2134
  return this.makeRequest<ChatSessionResponse>(
1903
2135
  "GET",
1904
- `/api/chat/${sessionId}`,
2136
+ `/api/chat/${encodeURIComponent(sessionId)}`,
1905
2137
  undefined,
1906
2138
  0,
1907
2139
  true, // Force JSON for chat operations
@@ -1944,8 +2176,8 @@ export class EkoDBClient {
1944
2176
 
1945
2177
  const queryString = params.toString();
1946
2178
  const path = queryString
1947
- ? `/api/chat/${sessionId}/messages?${queryString}`
1948
- : `/api/chat/${sessionId}/messages`;
2179
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
2180
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
1949
2181
  return this.makeRequest<GetMessagesResponse>(
1950
2182
  "GET",
1951
2183
  path,
@@ -1964,7 +2196,7 @@ export class EkoDBClient {
1964
2196
  ): Promise<ChatSessionResponse> {
1965
2197
  return this.makeRequest<ChatSessionResponse>(
1966
2198
  "PUT",
1967
- `/api/chat/${sessionId}`,
2199
+ `/api/chat/${encodeURIComponent(sessionId)}`,
1968
2200
  request,
1969
2201
  0,
1970
2202
  true, // Force JSON for chat operations
@@ -1992,7 +2224,7 @@ export class EkoDBClient {
1992
2224
  async deleteChatSession(sessionId: string): Promise<void> {
1993
2225
  await this.makeRequest<void>(
1994
2226
  "DELETE",
1995
- `/api/chat/${sessionId}`,
2227
+ `/api/chat/${encodeURIComponent(sessionId)}`,
1996
2228
  undefined,
1997
2229
  0,
1998
2230
  true, // Force JSON for chat operations
@@ -2008,7 +2240,7 @@ export class EkoDBClient {
2008
2240
  ): Promise<ChatResponse> {
2009
2241
  return this.makeRequest<ChatResponse>(
2010
2242
  "POST",
2011
- `/api/chat/${sessionId}/messages/${messageId}/regenerate`,
2243
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`,
2012
2244
  undefined,
2013
2245
  0,
2014
2246
  true, // Force JSON for chat operations
@@ -2025,7 +2257,7 @@ export class EkoDBClient {
2025
2257
  ): Promise<void> {
2026
2258
  await this.makeRequest<void>(
2027
2259
  "PUT",
2028
- `/api/chat/${sessionId}/messages/${messageId}`,
2260
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2029
2261
  { content },
2030
2262
  0,
2031
2263
  true, // Force JSON for chat operations
@@ -2038,7 +2270,7 @@ export class EkoDBClient {
2038
2270
  async deleteChatMessage(sessionId: string, messageId: string): Promise<void> {
2039
2271
  await this.makeRequest<void>(
2040
2272
  "DELETE",
2041
- `/api/chat/${sessionId}/messages/${messageId}`,
2273
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2042
2274
  undefined,
2043
2275
  0,
2044
2276
  true, // Force JSON for chat operations
@@ -2055,7 +2287,7 @@ export class EkoDBClient {
2055
2287
  ): Promise<void> {
2056
2288
  await this.makeRequest<void>(
2057
2289
  "PATCH",
2058
- `/api/chat/${sessionId}/messages/${messageId}/forgotten`,
2290
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`,
2059
2291
  { forgotten },
2060
2292
  0,
2061
2293
  true, // Force JSON for chat operations
@@ -2082,7 +2314,7 @@ export class EkoDBClient {
2082
2314
  }
2083
2315
  return this.makeRequest<CompactChatResponse>(
2084
2316
  "POST",
2085
- `/api/chat/${chatId}/compact`,
2317
+ `/api/chat/${encodeURIComponent(chatId)}/compact`,
2086
2318
  body,
2087
2319
  0,
2088
2320
  true, // Force JSON for chat operations
@@ -2157,7 +2389,7 @@ export class EkoDBClient {
2157
2389
  async getChatMessage(sessionId: string, messageId: string): Promise<Record> {
2158
2390
  return this.makeRequest<Record>(
2159
2391
  "GET",
2160
- `/api/chat/${sessionId}/messages/${messageId}`,
2392
+ `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
2161
2393
  undefined,
2162
2394
  0,
2163
2395
  true, // Force JSON for chat operations
@@ -2184,7 +2416,10 @@ export class EkoDBClient {
2184
2416
  * Get a function by ID
2185
2417
  */
2186
2418
  async getFunction(id: string): Promise<UserFunction> {
2187
- return this.makeRequest<UserFunction>("GET", `/api/functions/${id}`);
2419
+ return this.makeRequest<UserFunction>(
2420
+ "GET",
2421
+ `/api/functions/${encodeURIComponent(id)}`,
2422
+ );
2188
2423
  }
2189
2424
 
2190
2425
  /**
@@ -2199,14 +2434,21 @@ export class EkoDBClient {
2199
2434
  * Update an existing function by ID
2200
2435
  */
2201
2436
  async updateFunction(id: string, script: UserFunction): Promise<void> {
2202
- await this.makeRequest<void>("PUT", `/api/functions/${id}`, script);
2437
+ await this.makeRequest<void>(
2438
+ "PUT",
2439
+ `/api/functions/${encodeURIComponent(id)}`,
2440
+ script,
2441
+ );
2203
2442
  }
2204
2443
 
2205
2444
  /**
2206
2445
  * Delete a function by ID
2207
2446
  */
2208
2447
  async deleteFunction(id: string): Promise<void> {
2209
- await this.makeRequest<void>("DELETE", `/api/functions/${id}`);
2448
+ await this.makeRequest<void>(
2449
+ "DELETE",
2450
+ `/api/functions/${encodeURIComponent(id)}`,
2451
+ );
2210
2452
  }
2211
2453
 
2212
2454
  /**
@@ -2218,7 +2460,7 @@ export class EkoDBClient {
2218
2460
  ): Promise<FunctionResult> {
2219
2461
  return this.makeRequest<FunctionResult>(
2220
2462
  "POST",
2221
- `/api/functions/${idOrLabel}`,
2463
+ `/api/functions/${encodeURIComponent(idOrLabel)}`,
2222
2464
  params || {},
2223
2465
  );
2224
2466
  }
@@ -2465,7 +2707,7 @@ export class EkoDBClient {
2465
2707
  async goalStepStart(id: string, stepIndex: number): Promise<Record> {
2466
2708
  return this.makeRequest<Record>(
2467
2709
  "POST",
2468
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`,
2710
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`,
2469
2711
  undefined,
2470
2712
  0,
2471
2713
  true,
@@ -2480,7 +2722,7 @@ export class EkoDBClient {
2480
2722
  ): Promise<Record> {
2481
2723
  return this.makeRequest<Record>(
2482
2724
  "POST",
2483
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`,
2725
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`,
2484
2726
  data,
2485
2727
  0,
2486
2728
  true,
@@ -2495,7 +2737,7 @@ export class EkoDBClient {
2495
2737
  ): Promise<Record> {
2496
2738
  return this.makeRequest<Record>(
2497
2739
  "POST",
2498
- `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`,
2740
+ `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`,
2499
2741
  data,
2500
2742
  0,
2501
2743
  true,
@@ -2956,10 +3198,18 @@ export class EkoDBClient {
2956
3198
  }
2957
3199
 
2958
3200
  /**
2959
- * Create a WebSocket client
3201
+ * Create a WebSocket client.
3202
+ *
3203
+ * The token is supplied as a provider bound to this client's
3204
+ * {@link getToken}, so every (re)connect re-evaluates (and proactively
3205
+ * refreshes) the auth token instead of snapshotting it once. This means a
3206
+ * reconnect after a token rotation uses the current token.
3207
+ *
3208
+ * @param wsURL - The WebSocket URL (e.g. `wss://host`); `/api/ws` is appended if absent.
3209
+ * @param options - Optional reconnect/timeout tunables.
2960
3210
  */
2961
- websocket(wsURL: string): WebSocketClient {
2962
- return new WebSocketClient(wsURL, this.token!);
3211
+ websocket(wsURL: string, options?: WebSocketClientOptions): WebSocketClient {
3212
+ return new WebSocketClient(wsURL, () => this.getToken(), options);
2963
3213
  }
2964
3214
 
2965
3215
  // ========== RAG Helper Methods ==========
@@ -3164,6 +3414,39 @@ export interface SubscribeOptions {
3164
3414
  filterValue?: string;
3165
3415
  }
3166
3416
 
3417
+ /**
3418
+ * A token provider: either a static token string, or a (possibly async)
3419
+ * function that returns a fresh token. When a function is supplied it is
3420
+ * re-invoked on every (re)connect, so a rotated/refreshed token is always
3421
+ * used for the new socket instead of a stale snapshot captured once.
3422
+ */
3423
+ export type TokenProvider =
3424
+ | string
3425
+ | (() => string | null | Promise<string | null>);
3426
+
3427
+ /** Tunables for the WebSocket client's reconnect + request-timeout behavior. */
3428
+ export interface WebSocketClientOptions {
3429
+ /**
3430
+ * Auto-reconnect after an unexpected socket close/error (not an explicit
3431
+ * `close()`/unsubscribe). Defaults to true.
3432
+ */
3433
+ autoReconnect?: boolean;
3434
+ /** Initial backoff delay in ms before the first reconnect attempt. Default 200. */
3435
+ reconnectInitialDelayMs?: number;
3436
+ /** Maximum backoff delay in ms (the cap for exponential growth). Default 5000. */
3437
+ reconnectMaxDelayMs?: number;
3438
+ /**
3439
+ * Maximum number of consecutive reconnect attempts before giving up.
3440
+ * 0 or undefined means unlimited. Default unlimited.
3441
+ */
3442
+ reconnectMaxAttempts?: number;
3443
+ /**
3444
+ * Per-request timeout in ms for request/response WS calls. If no response
3445
+ * arrives in this window the pending promise rejects. Default 30000.
3446
+ */
3447
+ requestTimeoutMs?: number;
3448
+ }
3449
+
3167
3450
  /** EventEmitter-like interface for subscriptions and chat streams. */
3168
3451
  export class EventStream<_T = unknown> {
3169
3452
  private listeners: Map<string, Array<(data: any) => void>> = new Map();
@@ -3305,13 +3588,17 @@ export function extractRecordId(
3305
3588
  for (const key of extraCandidates) {
3306
3589
  const val = record[key];
3307
3590
  if (typeof val === "string") return val;
3308
- if (val && typeof val === "object" && "value" in val)
3591
+ // Unwrap only a genuine typed wrapper (both "type" and "value"), matching
3592
+ // getValue's rule so a user object like { value: 1 } isn't mistaken for one.
3593
+ if (val && typeof val === "object" && "type" in val && "value" in val)
3309
3594
  return String(val.value);
3310
3595
  }
3311
3596
  for (const key of ["id", "_id"]) {
3312
3597
  const val = record[key];
3313
3598
  if (typeof val === "string") return val;
3314
- if (val && typeof val === "object" && "value" in val)
3599
+ // Unwrap only a genuine typed wrapper (both "type" and "value"), matching
3600
+ // getValue's rule so a user object like { value: 1 } isn't mistaken for one.
3601
+ if (val && typeof val === "object" && "type" in val && "value" in val)
3315
3602
  return String(val.value);
3316
3603
  }
3317
3604
  return undefined;
@@ -3319,27 +3606,72 @@ export function extractRecordId(
3319
3606
 
3320
3607
  export class WebSocketClient {
3321
3608
  private wsURL: string;
3322
- private token: string;
3609
+ private tokenProvider: () => string | null | Promise<string | null>;
3323
3610
  private ws: any = null;
3324
3611
  private dispatcherRunning = false;
3325
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;
3620
+
3621
+ // Reconnect config
3622
+ private autoReconnect: boolean;
3623
+ private reconnectInitialDelayMs: number;
3624
+ private reconnectMaxDelayMs: number;
3625
+ private reconnectMaxAttempts: number;
3626
+ private requestTimeoutMs: number;
3627
+
3628
+ // Reconnect state
3629
+ /** Set while close() is in progress so the close handler doesn't reconnect. */
3630
+ private closed = false;
3631
+ private reconnectAttempts = 0;
3632
+ private reconnecting = false;
3633
+ private connectPromise: Promise<void> | null = null;
3326
3634
 
3327
3635
  // Dispatcher state
3328
3636
  private pendingRequests: Map<
3329
3637
  string,
3330
- { resolve: (value: any) => void; reject: (reason: any) => void }
3638
+ {
3639
+ resolve: (value: any) => void;
3640
+ reject: (reason: any) => void;
3641
+ timer?: ReturnType<typeof setTimeout>;
3642
+ }
3331
3643
  > = new Map();
3332
3644
  private subscriptions: Map<string, EventStream<MutationNotification>> =
3333
3645
  new Map();
3646
+ /** Bookkeeping so subscriptions can be replayed on reconnect. */
3647
+ private subscriptionParams: Map<string, SubscribeOptions | undefined> =
3648
+ new Map();
3334
3649
  private chatStreams: Map<string, EventStream<ChatStreamEvent>> = new Map();
3335
3650
  private registerToolsAck: {
3336
3651
  resolve: (value: any) => void;
3337
3652
  reject: (reason: any) => void;
3338
3653
  } | null = null;
3339
3654
 
3340
- constructor(wsURL: string, token: string) {
3341
- this.wsURL = wsURL;
3342
- this.token = token;
3655
+ /**
3656
+ * @param wsURL - WebSocket URL; `/api/ws` is appended if absent.
3657
+ * @param token - A static token string OR a {@link TokenProvider} function
3658
+ * re-evaluated on every (re)connect (so a refreshed token is used after a drop).
3659
+ * @param options - Optional reconnect/timeout tunables.
3660
+ */
3661
+ constructor(
3662
+ wsURL: string,
3663
+ token: TokenProvider,
3664
+ options: WebSocketClientOptions = {},
3665
+ ) {
3666
+ // Strip trailing slashes so appending `/api/ws` can't yield `//api/ws`,
3667
+ // which warp's exact path match (`api / ws`) would reject.
3668
+ this.wsURL = stripTrailingSlashes(wsURL);
3669
+ this.tokenProvider = typeof token === "function" ? token : () => token;
3670
+ this.autoReconnect = options.autoReconnect ?? true;
3671
+ this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 200;
3672
+ this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 5000;
3673
+ this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? 0;
3674
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
3343
3675
  }
3344
3676
 
3345
3677
  private messageCounter = 0;
@@ -3350,11 +3682,39 @@ export class WebSocketClient {
3350
3682
  }
3351
3683
 
3352
3684
  /**
3353
- * Connect and start the dispatcher.
3685
+ * Compute the capped exponential backoff (with jitter) for a reconnect
3686
+ * attempt. attempt 0 -> ~initial, growing x2 each time up to the max cap.
3687
+ * Jitter is +/-25% to avoid thundering-herd reconnect storms.
3688
+ * @internal exposed for testing
3689
+ */
3690
+ computeBackoff(attempt: number): number {
3691
+ const base = Math.min(
3692
+ this.reconnectInitialDelayMs * 2 ** attempt,
3693
+ this.reconnectMaxDelayMs,
3694
+ );
3695
+ const jitter = base * 0.25 * (Math.random() * 2 - 1);
3696
+ return Math.max(0, Math.round(base + jitter));
3697
+ }
3698
+
3699
+ /**
3700
+ * Connect and start the dispatcher. Re-evaluates the token provider so the
3701
+ * current/refreshed token is used for this socket.
3354
3702
  */
3355
3703
  private async ensureConnected(): Promise<void> {
3356
3704
  if (this.ws && this.dispatcherRunning) return;
3705
+ // Coalesce concurrent connect attempts onto a single in-flight promise.
3706
+ if (this.connectPromise) return this.connectPromise;
3707
+ // Clear the intentional-close flag only for user-initiated connects. During
3708
+ // a reconnect cycle this stays untouched so a concurrent close() can't be
3709
+ // undone and have the reconnect proceed against the user's intent.
3710
+ if (!this.reconnecting) this.closed = false;
3711
+ this.connectPromise = this.openSocket().finally(() => {
3712
+ this.connectPromise = null;
3713
+ });
3714
+ return this.connectPromise;
3715
+ }
3357
3716
 
3717
+ private async openSocket(): Promise<void> {
3358
3718
  const WebSocket = (await import("ws")).default;
3359
3719
 
3360
3720
  let url = this.wsURL;
@@ -3362,9 +3722,19 @@ export class WebSocketClient {
3362
3722
  url += "/api/ws";
3363
3723
  }
3364
3724
 
3725
+ // Re-evaluate the token on every (re)connect — never a stale snapshot.
3726
+ const token = await this.tokenProvider();
3727
+ if (!token) {
3728
+ // Fail fast with a clear error instead of sending `Bearer null`, which
3729
+ // would surface as a confusing 401 from the server.
3730
+ throw new Error(
3731
+ "WebSocket auth token is unavailable (the token provider returned null/empty)",
3732
+ );
3733
+ }
3734
+
3365
3735
  this.ws = new WebSocket(url, {
3366
3736
  headers: {
3367
- Authorization: `Bearer ${this.token}`,
3737
+ Authorization: `Bearer ${token}`,
3368
3738
  },
3369
3739
  });
3370
3740
 
@@ -3373,42 +3743,245 @@ export class WebSocketClient {
3373
3743
  this.ws.on("error", (err: Error) => reject(err));
3374
3744
  });
3375
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
+
3376
3751
  this.spawnDispatcher();
3377
3752
  }
3378
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
+
3379
3812
  private spawnDispatcher(): void {
3380
3813
  if (this.dispatcherRunning) return;
3381
3814
  this.dispatcherRunning = true;
3382
3815
 
3383
- this.ws.on("message", (data: Buffer) => {
3816
+ // Capture the socket this dispatcher is bound to. After a reconnect, the old
3817
+ // socket may still emit late close/error events; ignore them so they don't
3818
+ // tear down the replacement connection.
3819
+ const socket = this.ws;
3820
+
3821
+ socket.on("message", (data: Buffer, isBinary: boolean) => {
3822
+ if (this.ws !== socket) return;
3384
3823
  try {
3385
- 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());
3386
3830
  this.routeMessage(msg);
3387
3831
  } catch {
3388
3832
  // Ignore malformed messages
3389
3833
  }
3390
3834
  });
3391
3835
 
3392
- this.ws.on("close", () => {
3393
- this.dispatcherRunning = false;
3394
- // Notify all pending requests
3395
- for (const [, pending] of this.pendingRequests) {
3396
- pending.reject(new Error("WebSocket connection closed"));
3397
- }
3398
- this.pendingRequests.clear();
3399
- // Close all chat streams
3400
- for (const [, stream] of this.chatStreams) {
3401
- stream.emit("event", { type: "error", error: "Connection closed" });
3402
- stream.close();
3403
- }
3404
- this.chatStreams.clear();
3405
- // Close all subscriptions
3836
+ // Both "close" and "error" mean this socket is dead. ws typically emits
3837
+ // "error" followed by "close", so route both through one handler and let the
3838
+ // identity check dedupe: the first to fire nulls this.ws, the second no-ops.
3839
+ const onDown = () => {
3840
+ if (this.ws !== socket) return;
3841
+ this.handleDisconnect();
3842
+ };
3843
+ socket.on("close", onDown);
3844
+ socket.on("error", onDown);
3845
+ }
3846
+
3847
+ /**
3848
+ * Reject in-flight requests and tear down the dead socket. If the close was
3849
+ * unexpected (not an explicit `close()`) and auto-reconnect is enabled,
3850
+ * schedule a reconnect that re-sends the active subscriptions.
3851
+ */
3852
+ private handleDisconnect(): void {
3853
+ this.dispatcherRunning = false;
3854
+ this.ws = null;
3855
+
3856
+ // Reject all in-flight pending requests so callers don't hang forever.
3857
+ for (const [, pending] of this.pendingRequests) {
3858
+ if (pending.timer) clearTimeout(pending.timer);
3859
+ pending.reject(new Error("WebSocket connection closed"));
3860
+ }
3861
+ this.pendingRequests.clear();
3862
+ if (this.registerToolsAck) {
3863
+ this.registerToolsAck.reject(new Error("WebSocket connection closed"));
3864
+ this.registerToolsAck = null;
3865
+ }
3866
+ // Close all chat streams (they are one-shot; not replayed on reconnect).
3867
+ for (const [, stream] of this.chatStreams) {
3868
+ stream.emit("event", { type: "error", error: "Connection closed" });
3869
+ stream.close();
3870
+ }
3871
+ this.chatStreams.clear();
3872
+
3873
+ const shouldReconnect =
3874
+ this.autoReconnect && !this.closed && this.subscriptionParams.size > 0;
3875
+
3876
+ if (shouldReconnect) {
3877
+ this.scheduleReconnect();
3878
+ } else {
3879
+ // No reconnect: tear down subscriptions too.
3406
3880
  for (const [, stream] of this.subscriptions) {
3407
3881
  stream.close();
3408
3882
  }
3409
3883
  this.subscriptions.clear();
3410
- this.ws = null;
3411
- });
3884
+ this.subscriptionParams.clear();
3885
+ }
3886
+ }
3887
+
3888
+ /**
3889
+ * Reconnect with capped exponential backoff + jitter, then re-send the
3890
+ * subscribe messages for every active subscription so the SAME EventStream
3891
+ * keeps delivering mutations after a transient drop.
3892
+ */
3893
+ private scheduleReconnect(): void {
3894
+ if (this.reconnecting) return;
3895
+ this.reconnecting = true;
3896
+
3897
+ const attempt = async (): Promise<void> => {
3898
+ // Bail if the client was closed, or if every subscription was torn down
3899
+ // (e.g. unsubscribed) while a reconnect was in-flight — reconnect was only
3900
+ // opted into because subscriptions existed, so there's nothing to restore.
3901
+ if (this.closed || this.subscriptionParams.size === 0) {
3902
+ this.reconnecting = false;
3903
+ return;
3904
+ }
3905
+ if (
3906
+ this.reconnectMaxAttempts > 0 &&
3907
+ this.reconnectAttempts >= this.reconnectMaxAttempts
3908
+ ) {
3909
+ // Give up: tear down subscriptions and notify consumers.
3910
+ this.reconnecting = false;
3911
+ for (const [, stream] of this.subscriptions) {
3912
+ stream.emit("error", "WebSocket reconnect failed");
3913
+ stream.close();
3914
+ }
3915
+ this.subscriptions.clear();
3916
+ this.subscriptionParams.clear();
3917
+ return;
3918
+ }
3919
+
3920
+ const delay = this.computeBackoff(this.reconnectAttempts);
3921
+ this.reconnectAttempts++;
3922
+ await new Promise((r) => setTimeout(r, delay));
3923
+
3924
+ // Re-check after the backoff delay: close() or a full unsubscribe may have
3925
+ // happened while we were waiting, in which case skip reopening the socket.
3926
+ if (this.closed || this.subscriptionParams.size === 0) {
3927
+ this.reconnecting = false;
3928
+ return;
3929
+ }
3930
+
3931
+ try {
3932
+ // Route through ensureConnected() so a request-driven connect and this
3933
+ // reconnect share one in-flight connectPromise/socket — opening two live
3934
+ // sockets would misroute responses.
3935
+ await this.ensureConnected();
3936
+ // close() may have been called while the connect was in-flight; if so,
3937
+ // tear down the freshly-opened socket instead of leaving it orphaned.
3938
+ if (this.closed) {
3939
+ try {
3940
+ this.ws?.close?.();
3941
+ } catch {
3942
+ /* already closing */
3943
+ }
3944
+ this.ws = null;
3945
+ this.dispatcherRunning = false;
3946
+ this.reconnecting = false;
3947
+ return;
3948
+ }
3949
+ // Success — reset backoff and replay subscriptions.
3950
+ this.reconnectAttempts = 0;
3951
+ this.reconnecting = false;
3952
+ await this.resubscribeAll();
3953
+ } catch {
3954
+ // Connect failed — schedule the next attempt WITHOUT recursive await so
3955
+ // a prolonged outage can't build an unbounded promise chain.
3956
+ setTimeout(() => void attempt(), 0);
3957
+ }
3958
+ };
3959
+
3960
+ void attempt();
3961
+ }
3962
+
3963
+ /** Re-send Subscribe frames for every tracked subscription after a reconnect. */
3964
+ private async resubscribeAll(): Promise<void> {
3965
+ for (const [collection, options] of this.subscriptionParams) {
3966
+ const stream = this.subscriptions.get(collection);
3967
+ if (!stream || stream.closed) continue;
3968
+ const messageId = this.genMessageId();
3969
+ const request: any = {
3970
+ type: "Subscribe",
3971
+ messageId,
3972
+ payload: {
3973
+ collection,
3974
+ ...(options?.filterField && { filter_field: options.filterField }),
3975
+ ...(options?.filterValue && { filter_value: options.filterValue }),
3976
+ },
3977
+ };
3978
+ try {
3979
+ await this.sendRequest(request);
3980
+ } catch {
3981
+ // If the re-subscribe ack fails, leave it tracked; the next
3982
+ // disconnect/reconnect cycle will attempt it again.
3983
+ }
3984
+ }
3412
3985
  }
3413
3986
 
3414
3987
  private routeMessage(msg: any): void {
@@ -3423,14 +3996,7 @@ export class WebSocketClient {
3423
3996
  msg.payload?.messageId;
3424
3997
  let matched = false;
3425
3998
  if (messageId && this.pendingRequests.has(messageId)) {
3426
- const pending = this.pendingRequests.get(messageId)!;
3427
- this.pendingRequests.delete(messageId);
3428
- if (msg.type === "Error") {
3429
- pending.reject(new Error(msg.message || "Unknown error"));
3430
- } else {
3431
- pending.resolve(msg.payload);
3432
- }
3433
- matched = true;
3999
+ matched = this.settlePending(messageId, msg.type === "Error", msg);
3434
4000
  }
3435
4001
  if (!matched && this.registerToolsAck) {
3436
4002
  const ack = this.registerToolsAck;
@@ -3442,18 +4008,14 @@ export class WebSocketClient {
3442
4008
  }
3443
4009
  matched = true;
3444
4010
  }
3445
- // Server doesn't echo messageId — if there's exactly one pending
4011
+ // Server doesn't echo messageId at all — if there's exactly one pending
3446
4012
  // request, deliver the response to it (sequential request/response).
3447
- if (!matched && this.pendingRequests.size === 1) {
3448
- const entry = this.pendingRequests.entries().next().value!;
3449
- const key = entry[0];
3450
- const pending = entry[1];
3451
- this.pendingRequests.delete(key);
3452
- if (msg.type === "Error") {
3453
- pending.reject(new Error(msg.message || "Unknown error"));
3454
- } else {
3455
- pending.resolve(msg.payload);
3456
- }
4013
+ // Only when messageId is absent: a present-but-unmatched id means a late
4014
+ // response for an already-settled/timed-out request, which must NOT be
4015
+ // misrouted to whatever request happens to still be pending.
4016
+ if (!matched && !messageId && this.pendingRequests.size === 1) {
4017
+ const key = this.pendingRequests.keys().next().value!;
4018
+ this.settlePending(key, msg.type === "Error", msg);
3457
4019
  }
3458
4020
  break;
3459
4021
  }
@@ -3555,16 +4117,52 @@ export class WebSocketClient {
3555
4117
  const messageId = request.messageId || request.message_id;
3556
4118
 
3557
4119
  return new Promise((resolve, reject) => {
3558
- this.pendingRequests.set(messageId, { resolve, reject });
4120
+ // Per-request timeout: reject if no response arrives in the window so a
4121
+ // dropped/never-answered response can't leave the promise pending forever.
4122
+ let timer: ReturnType<typeof setTimeout> | undefined;
4123
+ if (this.requestTimeoutMs > 0) {
4124
+ timer = setTimeout(() => {
4125
+ if (this.pendingRequests.delete(messageId)) {
4126
+ reject(
4127
+ new Error(
4128
+ `WebSocket request "${request.type}" timed out after ${this.requestTimeoutMs}ms`,
4129
+ ),
4130
+ );
4131
+ }
4132
+ }, this.requestTimeoutMs);
4133
+ // Don't keep the process alive just for this timer.
4134
+ (timer as any)?.unref?.();
4135
+ }
4136
+
4137
+ this.pendingRequests.set(messageId, { resolve, reject, timer });
3559
4138
  try {
3560
- this.ws.send(JSON.stringify(request));
4139
+ this.sendFrame(request);
3561
4140
  } catch (err) {
3562
4141
  this.pendingRequests.delete(messageId);
4142
+ if (timer) clearTimeout(timer);
3563
4143
  reject(err);
3564
4144
  }
3565
4145
  });
3566
4146
  }
3567
4147
 
4148
+ /** Resolve/reject a pending request, clearing its timeout timer. */
4149
+ private settlePending(
4150
+ messageId: string,
4151
+ isError: boolean,
4152
+ msg: any,
4153
+ ): boolean {
4154
+ const pending = this.pendingRequests.get(messageId);
4155
+ if (!pending) return false;
4156
+ this.pendingRequests.delete(messageId);
4157
+ if (pending.timer) clearTimeout(pending.timer);
4158
+ if (isError) {
4159
+ pending.reject(new Error(msg.message || "Unknown error"));
4160
+ } else {
4161
+ pending.resolve(msg.payload);
4162
+ }
4163
+ return true;
4164
+ }
4165
+
3568
4166
  /**
3569
4167
  * Find all records in a collection via WebSocket.
3570
4168
  */
@@ -3595,6 +4193,8 @@ export class WebSocketClient {
3595
4193
  const messageId = this.genMessageId();
3596
4194
  const stream = new EventStream<MutationNotification>();
3597
4195
  this.subscriptions.set(collection, stream);
4196
+ // Track params so the subscription can be replayed on reconnect.
4197
+ this.subscriptionParams.set(collection, options);
3598
4198
 
3599
4199
  const request: any = {
3600
4200
  type: "Subscribe",
@@ -3611,11 +4211,45 @@ export class WebSocketClient {
3611
4211
  await this.sendRequest(request);
3612
4212
  } catch (err) {
3613
4213
  this.subscriptions.delete(collection);
4214
+ this.subscriptionParams.delete(collection);
3614
4215
  throw err;
3615
4216
  }
3616
4217
  return stream;
3617
4218
  }
3618
4219
 
4220
+ /**
4221
+ * Unsubscribe from a collection's mutation notifications. This is an
4222
+ * intentional teardown, so the subscription is NOT replayed on reconnect.
4223
+ */
4224
+ unsubscribe(collection: string): void {
4225
+ const stream = this.subscriptions.get(collection);
4226
+ this.subscriptions.delete(collection);
4227
+ this.subscriptionParams.delete(collection);
4228
+ if (stream && !stream.closed) {
4229
+ stream.close();
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
+ }
4251
+ }
4252
+
3619
4253
  /**
3620
4254
  * Send a chat message and receive a streaming response.
3621
4255
  * Returns an EventStream that emits "event" with ChatStreamEvent objects.
@@ -3651,7 +4285,7 @@ export class WebSocketClient {
3651
4285
  },
3652
4286
  };
3653
4287
 
3654
- this.ws.send(JSON.stringify(request));
4288
+ this.sendFrame(request);
3655
4289
  return stream;
3656
4290
  }
3657
4291
 
@@ -3677,7 +4311,7 @@ export class WebSocketClient {
3677
4311
  resolve: () => resolve(),
3678
4312
  reject: (err) => reject(err),
3679
4313
  };
3680
- this.ws.send(JSON.stringify(request));
4314
+ this.sendFrame(request);
3681
4315
  });
3682
4316
  }
3683
4317
 
@@ -3704,7 +4338,27 @@ export class WebSocketClient {
3704
4338
  },
3705
4339
  };
3706
4340
 
3707
- 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);
3708
4362
  }
3709
4363
 
3710
4364
  /**
@@ -3930,8 +4584,45 @@ export class WebSocketClient {
3930
4584
 
3931
4585
  /**
3932
4586
  * Close the WebSocket connection.
4587
+ *
4588
+ * This is an INTENTIONAL close: it disables auto-reconnect, rejects any
4589
+ * in-flight requests, and tears down all subscriptions/chat streams so
4590
+ * nothing is replayed afterward.
3933
4591
  */
3934
4592
  close(): void {
4593
+ // Mark intentional so the close handler doesn't trigger a reconnect.
4594
+ this.closed = true;
4595
+ this.reconnecting = false;
4596
+
4597
+ // Reject any in-flight requests and clear their timers.
4598
+ for (const [, pending] of this.pendingRequests) {
4599
+ if (pending.timer) clearTimeout(pending.timer);
4600
+ pending.reject(new Error("WebSocket connection closed"));
4601
+ }
4602
+ this.pendingRequests.clear();
4603
+
4604
+ // Tear down subscriptions + their replay bookkeeping.
4605
+ for (const [, stream] of this.subscriptions) {
4606
+ if (!stream.closed) stream.close();
4607
+ }
4608
+ this.subscriptions.clear();
4609
+ this.subscriptionParams.clear();
4610
+
4611
+ // Reject any in-flight tool registration ack. Done here (not just in the
4612
+ // ws "close" handler) so it's cleaned up even when this.ws is already null.
4613
+ if (this.registerToolsAck) {
4614
+ this.registerToolsAck.reject(new Error("WebSocket connection closed"));
4615
+ this.registerToolsAck = null;
4616
+ }
4617
+
4618
+ // Tear down chat streams immediately; they are one-shot and not replayed,
4619
+ // and we can't rely on the underlying ws "close" event having fired.
4620
+ for (const [, stream] of this.chatStreams) {
4621
+ stream.emit("event", { type: "error", error: "Connection closed" });
4622
+ stream.close();
4623
+ }
4624
+ this.chatStreams.clear();
4625
+
3935
4626
  if (this.ws) {
3936
4627
  this.ws.close();
3937
4628
  this.ws = null;