@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/dist/client.js CHANGED
@@ -69,6 +69,19 @@ var MergeStrategy;
69
69
  MergeStrategy["LatestOnly"] = "LatestOnly";
70
70
  MergeStrategy["Interleaved"] = "Interleaved";
71
71
  })(MergeStrategy || (exports.MergeStrategy = MergeStrategy = {}));
72
+ /**
73
+ * Strip trailing slashes from a base URL so path concatenation
74
+ * (`${base}/api/...`) never yields a double-slash path. Uses a linear scan
75
+ * rather than a regex like `/\/+$/`, which CodeQL flags as polynomial-time
76
+ * backtracking on caller-supplied input.
77
+ */
78
+ function stripTrailingSlashes(url) {
79
+ let end = url.length;
80
+ while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
81
+ end--;
82
+ }
83
+ return end === url.length ? url : url.slice(0, end);
84
+ }
72
85
  class EkoDBClient {
73
86
  constructor(config, apiKey) {
74
87
  this.token = null;
@@ -76,14 +89,16 @@ class EkoDBClient {
76
89
  this.rateLimitInfo = null;
77
90
  // Support both old (baseURL, apiKey) and new (config object) signatures
78
91
  if (typeof config === "string") {
79
- this.baseURL = config;
92
+ // Strip trailing slashes so `${baseURL}/api/...` never produces a
93
+ // double-slash path (some servers/proxies reject `//api/...`).
94
+ this.baseURL = stripTrailingSlashes(config);
80
95
  this.apiKey = apiKey;
81
96
  this.shouldRetry = true;
82
97
  this.maxRetries = 3;
83
98
  this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
84
99
  }
85
100
  else {
86
- this.baseURL = config.baseURL;
101
+ this.baseURL = stripTrailingSlashes(config.baseURL);
87
102
  this.apiKey = config.apiKey;
88
103
  this.shouldRetry = config.shouldRetry ?? true;
89
104
  this.maxRetries = config.maxRetries ?? 3;
@@ -223,6 +238,38 @@ class EkoDBClient {
223
238
  sleep(seconds) {
224
239
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
225
240
  }
241
+ /**
242
+ * Parse a `Retry-After` header into a non-negative delay in seconds.
243
+ *
244
+ * Per RFC 9110 the value is either delay-seconds (an integer) or an
245
+ * HTTP-date. Anything that doesn't resolve to a finite, non-negative number
246
+ * (missing header, garbage, a past date) falls back to `defaultSecs`.
247
+ */
248
+ parseRetryAfter(header, defaultSecs = 60) {
249
+ if (!header)
250
+ return defaultSecs;
251
+ // delay-seconds form: a bare integer.
252
+ const secs = Number(header.trim());
253
+ if (Number.isFinite(secs))
254
+ return Math.max(0, secs);
255
+ // HTTP-date form: compute the delay from now.
256
+ const dateMs = Date.parse(header);
257
+ if (Number.isFinite(dateMs)) {
258
+ return Math.max(0, (dateMs - Date.now()) / 1000);
259
+ }
260
+ return defaultSecs;
261
+ }
262
+ /**
263
+ * Backoff delay (in seconds) for a 0-indexed retry attempt: a capped
264
+ * exponential schedule (0.2s → 5s) with full jitter, so concurrent clients
265
+ * don't retry in lockstep. Returns a value in [d/2, d].
266
+ */
267
+ backoffSeconds(attempt) {
268
+ const base = 0.2;
269
+ const max = 5;
270
+ const d = Math.min(base * Math.pow(2, Math.max(0, attempt)), max);
271
+ return d / 2 + Math.random() * (d / 2);
272
+ }
226
273
  /**
227
274
  * Helper to determine if a path should use JSON
228
275
  * Only CRUD operations (insert/update/delete/batch) use MessagePack
@@ -292,10 +339,13 @@ class EkoDBClient {
292
339
  }
293
340
  // Handle rate limiting (429)
294
341
  if (response.status === 429) {
295
- const retryAfter = parseInt(response.headers.get("retry-after") || "60", 10);
342
+ const retryAfter = this.parseRetryAfter(response.headers.get("retry-after"));
296
343
  if (this.shouldRetry && attempt < this.maxRetries) {
297
- console.log(`Rate limited. Retrying after ${retryAfter} seconds...`);
298
- await this.sleep(retryAfter);
344
+ // Honor the server's Retry-After, but cap it so a hostile/large value
345
+ // can't pin the client for minutes.
346
+ const wait = Math.min(retryAfter, 60);
347
+ console.log(`Rate limited. Retrying after ${wait} seconds...`);
348
+ await this.sleep(wait);
299
349
  return this.makeRequest(method, path, data, attempt + 1, forceJson);
300
350
  }
301
351
  throw new RateLimitError(retryAfter);
@@ -311,8 +361,8 @@ class EkoDBClient {
311
361
  if (response.status === 503 &&
312
362
  this.shouldRetry &&
313
363
  attempt < this.maxRetries) {
314
- const retryDelay = 10;
315
- console.log(`Service unavailable. Retrying after ${retryDelay} seconds...`);
364
+ const retryDelay = this.backoffSeconds(attempt);
365
+ console.log(`Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`);
316
366
  await this.sleep(retryDelay);
317
367
  return this.makeRequest(method, path, data, attempt + 1, forceJson);
318
368
  }
@@ -325,8 +375,8 @@ class EkoDBClient {
325
375
  if (error instanceof TypeError &&
326
376
  this.shouldRetry &&
327
377
  attempt < this.maxRetries) {
328
- const retryDelay = 3;
329
- console.log(`Network error. Retrying after ${retryDelay} seconds...`);
378
+ const retryDelay = this.backoffSeconds(attempt);
379
+ console.log(`Network error. Retrying after ${retryDelay.toFixed(2)}s...`);
330
380
  await this.sleep(retryDelay);
331
381
  return this.makeRequest(method, path, data, attempt + 1, forceJson);
332
382
  }
@@ -352,8 +402,8 @@ class EkoDBClient {
352
402
  params.append("transaction_id", options.transactionId);
353
403
  }
354
404
  const url = params.toString()
355
- ? `/api/insert/${collection}?${params.toString()}`
356
- : `/api/insert/${collection}`;
405
+ ? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
406
+ : `/api/insert/${encodeURIComponent(collection)}`;
357
407
  return this.makeRequest("POST", url, data);
358
408
  }
359
409
  /**
@@ -378,15 +428,57 @@ class EkoDBClient {
378
428
  * const results = await client.find("users", { limit: 10 });
379
429
  * ```
380
430
  */
381
- async find(collection, query = {}) {
431
+ async find(collection, query = {}, options) {
382
432
  const queryObj = query instanceof query_builder_1.QueryBuilder ? query.build() : query;
383
- return this.makeRequest("POST", `/api/find/${collection}`, queryObj);
433
+ // bypass_ripple and transaction_id are query parameters — the same way every
434
+ // other method (insert/update/findById) carries bypass_ripple — not part of
435
+ // the FindBody. Hoist any bypass_ripple carried on the query object (e.g. from
436
+ // QueryBuilder.bypassRipple()) out of the body so it is ALWAYS sent as a query
437
+ // param; an explicit options.bypassRipple wins.
438
+ let body = queryObj;
439
+ let bypassRipple = options?.bypassRipple;
440
+ if (body && typeof body === "object" && "bypass_ripple" in body) {
441
+ const { bypass_ripple, ...rest } = body;
442
+ body = rest;
443
+ if (bypassRipple === undefined)
444
+ bypassRipple = bypass_ripple;
445
+ }
446
+ const params = new URLSearchParams();
447
+ if (options?.transactionId)
448
+ params.append("transaction_id", options.transactionId);
449
+ if (bypassRipple !== undefined)
450
+ params.append("bypass_ripple", String(bypassRipple));
451
+ const qs = params.toString();
452
+ const url = qs
453
+ ? `/api/find/${encodeURIComponent(collection)}?${qs}`
454
+ : `/api/find/${encodeURIComponent(collection)}`;
455
+ return this.makeRequest("POST", url, body);
384
456
  }
385
457
  /**
386
- * Find a document by ID
458
+ * Find a document by ID.
459
+ * @param options - Optional read options. `transactionId` reads within a
460
+ * transaction (read-your-writes); see {@link FindByIdOptions}.
387
461
  */
388
- async findById(collection, id) {
389
- return this.makeRequest("GET", `/api/find/${collection}/${id}`);
462
+ async findById(collection, id, options) {
463
+ const params = new URLSearchParams();
464
+ if (options?.selectFields?.length) {
465
+ params.append("select_fields", options.selectFields.join(","));
466
+ }
467
+ if (options?.excludeFields?.length) {
468
+ params.append("exclude_fields", options.excludeFields.join(","));
469
+ }
470
+ // bypass_ripple is a GET query param, the same way the non-transactional
471
+ // findById carries it; it rides alongside transaction_id when both are set.
472
+ if (options?.bypassRipple !== undefined) {
473
+ params.append("bypass_ripple", String(options.bypassRipple));
474
+ }
475
+ if (options?.transactionId) {
476
+ params.append("transaction_id", options.transactionId);
477
+ }
478
+ const url = params.toString()
479
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
480
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
481
+ return this.makeRequest("GET", url);
390
482
  }
391
483
  /**
392
484
  * Find a document by ID with field projection
@@ -394,8 +486,9 @@ class EkoDBClient {
394
486
  * @param id - Document ID
395
487
  * @param selectFields - Fields to include in the result
396
488
  * @param excludeFields - Fields to exclude from the result
489
+ * @param transactionId - Read within a transaction (read-your-writes)
397
490
  */
398
- async findByIdWithProjection(collection, id, selectFields, excludeFields) {
491
+ async findByIdWithProjection(collection, id, selectFields, excludeFields, transactionId) {
399
492
  const params = new URLSearchParams();
400
493
  if (selectFields?.length) {
401
494
  params.append("select_fields", selectFields.join(","));
@@ -403,9 +496,12 @@ class EkoDBClient {
403
496
  if (excludeFields?.length) {
404
497
  params.append("exclude_fields", excludeFields.join(","));
405
498
  }
499
+ if (transactionId) {
500
+ params.append("transaction_id", transactionId);
501
+ }
406
502
  const url = params.toString()
407
- ? `/api/find/${collection}/${id}?${params.toString()}`
408
- : `/api/find/${collection}/${id}`;
503
+ ? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
504
+ : `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
409
505
  return this.makeRequest("GET", url);
410
506
  }
411
507
  /**
@@ -424,8 +520,8 @@ class EkoDBClient {
424
520
  params.append("transaction_id", options.transactionId);
425
521
  }
426
522
  const url = params.toString()
427
- ? `/api/update/${collection}/${id}?${params.toString()}`
428
- : `/api/update/${collection}/${id}`;
523
+ ? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
524
+ : `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
429
525
  return this.makeRequest("PUT", url, record);
430
526
  }
431
527
  /**
@@ -442,7 +538,7 @@ class EkoDBClient {
442
538
  * @param value - The value for the action (omit for pop/shift/clear)
443
539
  */
444
540
  async updateWithAction(collection, id, action, field, value) {
445
- const url = `/api/update/${collection}/${id}/action/${action}`;
541
+ const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
446
542
  return this.makeRequest("PUT", url, {
447
543
  field,
448
544
  value: value ?? null,
@@ -459,7 +555,7 @@ class EkoDBClient {
459
555
  * @param actions - Array of [action, field, value] tuples
460
556
  */
461
557
  async updateWithActionSequence(collection, id, actions) {
462
- const url = `/api/update/sequence/${collection}/${id}`;
558
+ const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
463
559
  return this.makeRequest("PUT", url, actions);
464
560
  }
465
561
  /**
@@ -477,8 +573,8 @@ class EkoDBClient {
477
573
  params.append("transaction_id", options.transactionId);
478
574
  }
479
575
  const url = params.toString()
480
- ? `/api/delete/${collection}/${id}?${params.toString()}`
481
- : `/api/delete/${collection}/${id}`;
576
+ ? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
577
+ : `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
482
578
  await this.makeRequest("DELETE", url);
483
579
  }
484
580
  /**
@@ -497,8 +593,8 @@ class EkoDBClient {
497
593
  }
498
594
  const inserts = records.map((data) => ({ data }));
499
595
  const url = params.toString()
500
- ? `/api/batch/insert/${collection}?${params.toString()}`
501
- : `/api/batch/insert/${collection}`;
596
+ ? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
597
+ : `/api/batch/insert/${encodeURIComponent(collection)}`;
502
598
  return this.makeRequest("POST", url, { inserts });
503
599
  }
504
600
  /**
@@ -510,7 +606,7 @@ class EkoDBClient {
510
606
  data: u.data,
511
607
  bypass_ripple: u.bypassRipple,
512
608
  }));
513
- return this.makeRequest("PUT", `/api/batch/update/${collection}`, { updates: formattedUpdates });
609
+ return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
514
610
  }
515
611
  /**
516
612
  * Batch delete multiple documents
@@ -520,7 +616,7 @@ class EkoDBClient {
520
616
  id: id,
521
617
  bypass_ripple: bypassRipple,
522
618
  }));
523
- return this.makeRequest("DELETE", `/api/batch/delete/${collection}`, { deletes });
619
+ return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
524
620
  }
525
621
  /**
526
622
  * Set a key-value pair with optional TTL
@@ -548,6 +644,12 @@ class EkoDBClient {
548
644
  async kvDelete(key) {
549
645
  await this.makeRequest("DELETE", `/api/kv/delete/${encodeURIComponent(key)}`, undefined, 0, true);
550
646
  }
647
+ /**
648
+ * Clear the entire KV store (all keys in the namespace).
649
+ */
650
+ async kvClear() {
651
+ await this.makeRequest("DELETE", "/api/kv/clear", undefined, 0, true);
652
+ }
551
653
  /**
552
654
  * Batch get multiple keys
553
655
  * @param keys - Array of keys to retrieve
@@ -613,7 +715,18 @@ class EkoDBClient {
613
715
  // Transaction Operations
614
716
  // ============================================================================
615
717
  /**
616
- * Begin a new transaction
718
+ * Begin a new transaction.
719
+ *
720
+ * Transactions are buffered: statements issued with this `transactionId`
721
+ * (passed via the `transactionId` option on insert/update/delete/find/…) are
722
+ * staged and applied atomically only at {@link commitTransaction}. They are
723
+ * invisible to everyone else until commit, and visible to this transaction's
724
+ * own reads (read-your-writes) only when those reads also carry the
725
+ * `transactionId`. {@link rollbackTransaction} discards the staged writes.
726
+ * `commitTransaction` may reject with a conflict (HTTP 409) if a record this
727
+ * transaction read or wrote was changed by another committed transaction —
728
+ * retry the transaction in that case.
729
+ *
617
730
  * @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
618
731
  * @returns Transaction ID
619
732
  */
@@ -637,12 +750,31 @@ class EkoDBClient {
637
750
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/commit`, undefined, 0, true);
638
751
  }
639
752
  /**
640
- * Rollback a transaction
753
+ * Rollback a transaction (discards all staged writes; nothing was applied).
641
754
  * @param transactionId - The transaction ID to rollback
642
755
  */
643
756
  async rollbackTransaction(transactionId) {
644
757
  await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/rollback`, undefined, 0, true);
645
758
  }
759
+ /**
760
+ * Create a savepoint within a transaction. A later
761
+ * {@link rollbackToSavepoint} discards everything staged after it.
762
+ */
763
+ async createSavepoint(transactionId, name) {
764
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints`, { name }, 0, true);
765
+ }
766
+ /**
767
+ * Roll the transaction back to a savepoint, discarding writes staged after it.
768
+ */
769
+ async rollbackToSavepoint(transactionId, name) {
770
+ await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}/rollback`, undefined, 0, true);
771
+ }
772
+ /**
773
+ * Release (forget) a savepoint. Staged work is unaffected.
774
+ */
775
+ async releaseSavepoint(transactionId, name) {
776
+ await this.makeRequest("DELETE", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}`, undefined, 0, true);
777
+ }
646
778
  // ============================================================================
647
779
  // Convenience Methods
648
780
  // ============================================================================
@@ -783,11 +915,18 @@ class EkoDBClient {
783
915
  const result = await this.makeRequest("GET", "/api/collections", undefined, 0, true);
784
916
  return result.collections;
785
917
  }
918
+ /**
919
+ * List collections, excluding internal chat/system collections.
920
+ */
921
+ async listUserCollections() {
922
+ const result = await this.makeRequest("GET", "/api/collections?exclude_internal=true", undefined, 0, true);
923
+ return result.collections;
924
+ }
786
925
  /**
787
926
  * Delete a collection
788
927
  */
789
928
  async deleteCollection(collection) {
790
- await this.makeRequest("DELETE", `/api/collections/${collection}`, undefined, 0, true);
929
+ await this.makeRequest("DELETE", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
791
930
  }
792
931
  /**
793
932
  * Restore a deleted record from trash
@@ -798,7 +937,7 @@ class EkoDBClient {
798
937
  * @returns true if restored successfully
799
938
  */
800
939
  async restoreRecord(collection, id) {
801
- const result = await this.makeRequest("POST", `/api/trash/${collection}/${id}`, undefined, 0, true);
940
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, undefined, 0, true);
802
941
  return result.status === "restored";
803
942
  }
804
943
  /**
@@ -809,7 +948,7 @@ class EkoDBClient {
809
948
  * @returns Number of records restored
810
949
  */
811
950
  async restoreCollection(collection) {
812
- const result = await this.makeRequest("POST", `/api/trash/${collection}`, undefined, 0, true);
951
+ const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}`, undefined, 0, true);
813
952
  return { recordsRestored: result.records_restored };
814
953
  }
815
954
  /**
@@ -830,7 +969,7 @@ class EkoDBClient {
830
969
  */
831
970
  async createCollection(collection, schema) {
832
971
  const schemaObj = schema instanceof schema_1.SchemaBuilder ? schema.build() : schema;
833
- await this.makeRequest("POST", `/api/collections/${collection}`, schemaObj, 0, true);
972
+ await this.makeRequest("POST", `/api/collections/${encodeURIComponent(collection)}`, schemaObj, 0, true);
834
973
  }
835
974
  /**
836
975
  * Get collection metadata and schema
@@ -839,7 +978,7 @@ class EkoDBClient {
839
978
  * @returns Collection metadata including schema and analytics
840
979
  */
841
980
  async getCollection(collection) {
842
- return this.makeRequest("GET", `/api/collections/${collection}`, undefined, 0, true);
981
+ return this.makeRequest("GET", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
843
982
  }
844
983
  /**
845
984
  * Get collection schema
@@ -886,7 +1025,7 @@ class EkoDBClient {
886
1025
  */
887
1026
  async search(collection, query) {
888
1027
  // Ensure all parameters from SearchQuery are sent to server
889
- return this.makeRequest("POST", `/api/search/${collection}`, query, 0, true);
1028
+ return this.makeRequest("POST", `/api/search/${encodeURIComponent(collection)}`, query, 0, true);
890
1029
  }
891
1030
  /**
892
1031
  * Get distinct (unique) values for a field across all records in a collection.
@@ -916,7 +1055,7 @@ class EkoDBClient {
916
1055
  body.bypass_ripple = options.bypassRipple;
917
1056
  if (options.bypassCache !== undefined)
918
1057
  body.bypass_cache = options.bypassCache;
919
- return this.makeRequest("POST", `/api/distinct/${collection}/${field}`, body, 0, true);
1058
+ return this.makeRequest("POST", `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`, body, 0, true);
920
1059
  }
921
1060
  /**
922
1061
  * Health check - verify the ekoDB server is responding
@@ -1096,14 +1235,14 @@ class EkoDBClient {
1096
1235
  * Send a message in an existing chat session
1097
1236
  */
1098
1237
  async chatMessage(sessionId, request) {
1099
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages`, request, 0, true);
1238
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages`, request, 0, true);
1100
1239
  }
1101
1240
  /**
1102
1241
  * Submit a client tool result for an in-flight SSE chat stream.
1103
1242
  * Unblocks ekoDB's tool loop so it can feed the result to the LLM.
1104
1243
  */
1105
1244
  async submitChatToolResult(chatId, callId, success, result, error) {
1106
- await this.makeRequest("POST", `/api/chat/${chatId}/tool-result`, {
1245
+ await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
1107
1246
  call_id: callId,
1108
1247
  success,
1109
1248
  ...(result !== undefined && { result }),
@@ -1125,12 +1264,12 @@ class EkoDBClient {
1125
1264
  const stream = new EventStream();
1126
1265
  (async () => {
1127
1266
  try {
1128
- let token = this.getToken();
1267
+ let token = await this.getToken();
1129
1268
  if (!token) {
1130
1269
  await this.refreshToken();
1131
- token = this.getToken();
1270
+ token = await this.getToken();
1132
1271
  }
1133
- const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
1272
+ const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
1134
1273
  const response = await fetch(url, {
1135
1274
  method: "POST",
1136
1275
  headers: {
@@ -1149,13 +1288,12 @@ class EkoDBClient {
1149
1288
  stream.close();
1150
1289
  return;
1151
1290
  }
1152
- const body = await response.text();
1153
- for (const line of body.split("\n")) {
1291
+ const emitLine = (line) => {
1154
1292
  if (!line.startsWith("data:"))
1155
- continue;
1293
+ return;
1156
1294
  const dataStr = line.slice(5).trim();
1157
1295
  if (!dataStr)
1158
- continue;
1296
+ return;
1159
1297
  try {
1160
1298
  const eventData = JSON.parse(dataStr);
1161
1299
  if (eventData.error) {
@@ -1184,6 +1322,33 @@ class EkoDBClient {
1184
1322
  catch {
1185
1323
  // skip malformed SSE data
1186
1324
  }
1325
+ };
1326
+ const reader = response.body?.getReader?.();
1327
+ if (reader) {
1328
+ // True incremental streaming: decode and emit each SSE line as soon as
1329
+ // it arrives, rather than buffering the entire response body first.
1330
+ const decoder = new TextDecoder();
1331
+ let buffer = "";
1332
+ for (;;) {
1333
+ const { done, value } = await reader.read();
1334
+ if (done)
1335
+ break;
1336
+ buffer += decoder.decode(value, { stream: true });
1337
+ let nl;
1338
+ while ((nl = buffer.indexOf("\n")) >= 0) {
1339
+ emitLine(buffer.slice(0, nl));
1340
+ buffer = buffer.slice(nl + 1);
1341
+ }
1342
+ }
1343
+ buffer += decoder.decode();
1344
+ if (buffer)
1345
+ emitLine(buffer);
1346
+ }
1347
+ else {
1348
+ // Fallback for environments/tests without a readable body stream.
1349
+ const body = await response.text();
1350
+ for (const line of body.split("\n"))
1351
+ emitLine(line);
1187
1352
  }
1188
1353
  stream.close();
1189
1354
  }
@@ -1201,7 +1366,7 @@ class EkoDBClient {
1201
1366
  * Get a chat session by ID
1202
1367
  */
1203
1368
  async getChatSession(sessionId) {
1204
- return this.makeRequest("GET", `/api/chat/${sessionId}`, undefined, 0, true);
1369
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1205
1370
  }
1206
1371
  /**
1207
1372
  * List all chat sessions
@@ -1231,15 +1396,15 @@ class EkoDBClient {
1231
1396
  params.append("sort", query.sort);
1232
1397
  const queryString = params.toString();
1233
1398
  const path = queryString
1234
- ? `/api/chat/${sessionId}/messages?${queryString}`
1235
- : `/api/chat/${sessionId}/messages`;
1399
+ ? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
1400
+ : `/api/chat/${encodeURIComponent(sessionId)}/messages`;
1236
1401
  return this.makeRequest("GET", path, undefined, 0, true);
1237
1402
  }
1238
1403
  /**
1239
1404
  * Update a chat session
1240
1405
  */
1241
1406
  async updateChatSession(sessionId, request) {
1242
- return this.makeRequest("PUT", `/api/chat/${sessionId}`, request, 0, true);
1407
+ return this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}`, request, 0, true);
1243
1408
  }
1244
1409
  /**
1245
1410
  * Branch a chat session
@@ -1251,31 +1416,31 @@ class EkoDBClient {
1251
1416
  * Delete a chat session
1252
1417
  */
1253
1418
  async deleteChatSession(sessionId) {
1254
- await this.makeRequest("DELETE", `/api/chat/${sessionId}`, undefined, 0, true);
1419
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
1255
1420
  }
1256
1421
  /**
1257
1422
  * Regenerate an AI response message
1258
1423
  */
1259
1424
  async regenerateMessage(sessionId, messageId) {
1260
- return this.makeRequest("POST", `/api/chat/${sessionId}/messages/${messageId}/regenerate`, undefined, 0, true);
1425
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`, undefined, 0, true);
1261
1426
  }
1262
1427
  /**
1263
1428
  * Update a specific message
1264
1429
  */
1265
1430
  async updateChatMessage(sessionId, messageId, content) {
1266
- await this.makeRequest("PUT", `/api/chat/${sessionId}/messages/${messageId}`, { content }, 0, true);
1431
+ await this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { content }, 0, true);
1267
1432
  }
1268
1433
  /**
1269
1434
  * Delete a specific message
1270
1435
  */
1271
1436
  async deleteChatMessage(sessionId, messageId) {
1272
- await this.makeRequest("DELETE", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1437
+ await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1273
1438
  }
1274
1439
  /**
1275
1440
  * Toggle the "forgotten" status of a message
1276
1441
  */
1277
1442
  async toggleForgottenMessage(sessionId, messageId, forgotten) {
1278
- await this.makeRequest("PATCH", `/api/chat/${sessionId}/messages/${messageId}/forgotten`, { forgotten }, 0, true);
1443
+ await this.makeRequest("PATCH", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`, { forgotten }, 0, true);
1279
1444
  }
1280
1445
  /**
1281
1446
  * Compact a chat session's history on demand.
@@ -1292,7 +1457,7 @@ class EkoDBClient {
1292
1457
  if (keepRecent !== undefined) {
1293
1458
  body.keep_recent = keepRecent;
1294
1459
  }
1295
- return this.makeRequest("POST", `/api/chat/${chatId}/compact`, body, 0, true);
1460
+ return this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/compact`, body, 0, true);
1296
1461
  }
1297
1462
  /**
1298
1463
  * Merge multiple chat sessions into one
@@ -1330,7 +1495,7 @@ class EkoDBClient {
1330
1495
  * @returns The chat message record
1331
1496
  */
1332
1497
  async getChatMessage(sessionId, messageId) {
1333
- return this.makeRequest("GET", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
1498
+ return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
1334
1499
  }
1335
1500
  // ========================================================================
1336
1501
  // SCRIPTS API
@@ -1346,7 +1511,7 @@ class EkoDBClient {
1346
1511
  * Get a function by ID
1347
1512
  */
1348
1513
  async getFunction(id) {
1349
- return this.makeRequest("GET", `/api/functions/${id}`);
1514
+ return this.makeRequest("GET", `/api/functions/${encodeURIComponent(id)}`);
1350
1515
  }
1351
1516
  /**
1352
1517
  * List all functions, optionally filtered by tags
@@ -1359,19 +1524,19 @@ class EkoDBClient {
1359
1524
  * Update an existing function by ID
1360
1525
  */
1361
1526
  async updateFunction(id, script) {
1362
- await this.makeRequest("PUT", `/api/functions/${id}`, script);
1527
+ await this.makeRequest("PUT", `/api/functions/${encodeURIComponent(id)}`, script);
1363
1528
  }
1364
1529
  /**
1365
1530
  * Delete a function by ID
1366
1531
  */
1367
1532
  async deleteFunction(id) {
1368
- await this.makeRequest("DELETE", `/api/functions/${id}`);
1533
+ await this.makeRequest("DELETE", `/api/functions/${encodeURIComponent(id)}`);
1369
1534
  }
1370
1535
  /**
1371
1536
  * Call a saved function by ID or label
1372
1537
  */
1373
1538
  async callFunction(idOrLabel, params) {
1374
- return this.makeRequest("POST", `/api/functions/${idOrLabel}`, params || {});
1539
+ return this.makeRequest("POST", `/api/functions/${encodeURIComponent(idOrLabel)}`, params || {});
1375
1540
  }
1376
1541
  // ========================================================================
1377
1542
  // USER FUNCTIONS API
@@ -1480,15 +1645,15 @@ class EkoDBClient {
1480
1645
  }
1481
1646
  /** Start a goal step (status -> in_progress) */
1482
1647
  async goalStepStart(id, stepIndex) {
1483
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`, undefined, 0, true);
1648
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`, undefined, 0, true);
1484
1649
  }
1485
1650
  /** Complete a goal step with result */
1486
1651
  async goalStepComplete(id, stepIndex, data) {
1487
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`, data, 0, true);
1652
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`, data, 0, true);
1488
1653
  }
1489
1654
  /** Fail a goal step with error */
1490
1655
  async goalStepFail(id, stepIndex, data) {
1491
- return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`, data, 0, true);
1656
+ return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`, data, 0, true);
1492
1657
  }
1493
1658
  // ========================================================================
1494
1659
  // TASK API
@@ -1736,10 +1901,18 @@ class EkoDBClient {
1736
1901
  return stream;
1737
1902
  }
1738
1903
  /**
1739
- * Create a WebSocket client
1904
+ * Create a WebSocket client.
1905
+ *
1906
+ * The token is supplied as a provider bound to this client's
1907
+ * {@link getToken}, so every (re)connect re-evaluates (and proactively
1908
+ * refreshes) the auth token instead of snapshotting it once. This means a
1909
+ * reconnect after a token rotation uses the current token.
1910
+ *
1911
+ * @param wsURL - The WebSocket URL (e.g. `wss://host`); `/api/ws` is appended if absent.
1912
+ * @param options - Optional reconnect/timeout tunables.
1740
1913
  */
1741
- websocket(wsURL) {
1742
- return new WebSocketClient(wsURL, this.token);
1914
+ websocket(wsURL, options) {
1915
+ return new WebSocketClient(wsURL, () => this.getToken(), options);
1743
1916
  }
1744
1917
  // ========== RAG Helper Methods ==========
1745
1918
  /**
@@ -1988,91 +2161,348 @@ function extractRecordId(record, extraCandidates = []) {
1988
2161
  const val = record[key];
1989
2162
  if (typeof val === "string")
1990
2163
  return val;
1991
- if (val && typeof val === "object" && "value" in val)
2164
+ // Unwrap only a genuine typed wrapper (both "type" and "value"), matching
2165
+ // getValue's rule so a user object like { value: 1 } isn't mistaken for one.
2166
+ if (val && typeof val === "object" && "type" in val && "value" in val)
1992
2167
  return String(val.value);
1993
2168
  }
1994
2169
  for (const key of ["id", "_id"]) {
1995
2170
  const val = record[key];
1996
2171
  if (typeof val === "string")
1997
2172
  return val;
1998
- if (val && typeof val === "object" && "value" in val)
2173
+ // Unwrap only a genuine typed wrapper (both "type" and "value"), matching
2174
+ // getValue's rule so a user object like { value: 1 } isn't mistaken for one.
2175
+ if (val && typeof val === "object" && "type" in val && "value" in val)
1999
2176
  return String(val.value);
2000
2177
  }
2001
2178
  return undefined;
2002
2179
  }
2003
2180
  class WebSocketClient {
2004
- constructor(wsURL, token) {
2181
+ /**
2182
+ * @param wsURL - WebSocket URL; `/api/ws` is appended if absent.
2183
+ * @param token - A static token string OR a {@link TokenProvider} function
2184
+ * re-evaluated on every (re)connect (so a refreshed token is used after a drop).
2185
+ * @param options - Optional reconnect/timeout tunables.
2186
+ */
2187
+ constructor(wsURL, token, options = {}) {
2005
2188
  this.ws = null;
2006
2189
  this.dispatcherRunning = false;
2007
2190
  this.schemaCache = null;
2191
+ /**
2192
+ * Per-connection wire format, set by negotiateFormat() on every (re)connect:
2193
+ * true once the server has Welcomed msgpack, so frames are sent/received as
2194
+ * binary msgpack; false (JSON text) otherwise, including against an older
2195
+ * server that never Welcomes. Keeps the transport fully back-compatible.
2196
+ */
2197
+ this.binary = false;
2198
+ // Reconnect state
2199
+ /** Set while close() is in progress so the close handler doesn't reconnect. */
2200
+ this.closed = false;
2201
+ this.reconnectAttempts = 0;
2202
+ this.reconnecting = false;
2203
+ this.connectPromise = null;
2008
2204
  // Dispatcher state
2009
2205
  this.pendingRequests = new Map();
2010
2206
  this.subscriptions = new Map();
2207
+ /** Bookkeeping so subscriptions can be replayed on reconnect. */
2208
+ this.subscriptionParams = new Map();
2011
2209
  this.chatStreams = new Map();
2012
2210
  this.registerToolsAck = null;
2013
2211
  this.messageCounter = 0;
2014
- this.wsURL = wsURL;
2015
- this.token = token;
2212
+ // Strip trailing slashes so appending `/api/ws` can't yield `//api/ws`,
2213
+ // which warp's exact path match (`api / ws`) would reject.
2214
+ this.wsURL = stripTrailingSlashes(wsURL);
2215
+ this.tokenProvider = typeof token === "function" ? token : () => token;
2216
+ this.autoReconnect = options.autoReconnect ?? true;
2217
+ this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 200;
2218
+ this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 5000;
2219
+ this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? 0;
2220
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
2016
2221
  }
2017
2222
  genMessageId() {
2018
2223
  const counter = this.messageCounter++;
2019
2224
  return `${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
2020
2225
  }
2021
2226
  /**
2022
- * Connect and start the dispatcher.
2227
+ * Compute the capped exponential backoff (with jitter) for a reconnect
2228
+ * attempt. attempt 0 -> ~initial, growing x2 each time up to the max cap.
2229
+ * Jitter is +/-25% to avoid thundering-herd reconnect storms.
2230
+ * @internal exposed for testing
2231
+ */
2232
+ computeBackoff(attempt) {
2233
+ const base = Math.min(this.reconnectInitialDelayMs * 2 ** attempt, this.reconnectMaxDelayMs);
2234
+ const jitter = base * 0.25 * (Math.random() * 2 - 1);
2235
+ return Math.max(0, Math.round(base + jitter));
2236
+ }
2237
+ /**
2238
+ * Connect and start the dispatcher. Re-evaluates the token provider so the
2239
+ * current/refreshed token is used for this socket.
2023
2240
  */
2024
2241
  async ensureConnected() {
2025
2242
  if (this.ws && this.dispatcherRunning)
2026
2243
  return;
2244
+ // Coalesce concurrent connect attempts onto a single in-flight promise.
2245
+ if (this.connectPromise)
2246
+ return this.connectPromise;
2247
+ // Clear the intentional-close flag only for user-initiated connects. During
2248
+ // a reconnect cycle this stays untouched so a concurrent close() can't be
2249
+ // undone and have the reconnect proceed against the user's intent.
2250
+ if (!this.reconnecting)
2251
+ this.closed = false;
2252
+ this.connectPromise = this.openSocket().finally(() => {
2253
+ this.connectPromise = null;
2254
+ });
2255
+ return this.connectPromise;
2256
+ }
2257
+ async openSocket() {
2027
2258
  const WebSocket = (await Promise.resolve().then(() => __importStar(require("ws")))).default;
2028
2259
  let url = this.wsURL;
2029
2260
  if (!url.endsWith("/api/ws")) {
2030
2261
  url += "/api/ws";
2031
2262
  }
2263
+ // Re-evaluate the token on every (re)connect — never a stale snapshot.
2264
+ const token = await this.tokenProvider();
2265
+ if (!token) {
2266
+ // Fail fast with a clear error instead of sending `Bearer null`, which
2267
+ // would surface as a confusing 401 from the server.
2268
+ throw new Error("WebSocket auth token is unavailable (the token provider returned null/empty)");
2269
+ }
2032
2270
  this.ws = new WebSocket(url, {
2033
2271
  headers: {
2034
- Authorization: `Bearer ${this.token}`,
2272
+ Authorization: `Bearer ${token}`,
2035
2273
  },
2036
2274
  });
2037
2275
  await new Promise((resolve, reject) => {
2038
2276
  this.ws.on("open", () => resolve());
2039
2277
  this.ws.on("error", (err) => reject(err));
2040
2278
  });
2279
+ // Negotiate the wire format before the dispatcher starts so the Welcome is
2280
+ // consumed here (not by routeMessage), and before any real frame is sent
2281
+ // (resubscribeAll runs only after this resolves).
2282
+ await this.negotiateFormat(this.ws);
2041
2283
  this.spawnDispatcher();
2042
2284
  }
2285
+ /**
2286
+ * Additive capability handshake: offer msgpack and, if the server Welcomes
2287
+ * it, switch this connection to binary msgpack frames; otherwise stay on JSON
2288
+ * text. The Welcome (a text frame) is read with a one-shot listener and a
2289
+ * timeout so an older server that never answers — or answers with an Error —
2290
+ * simply leaves the connection on JSON. Best-effort and never throws: JSON
2291
+ * always works.
2292
+ */
2293
+ async negotiateFormat(socket) {
2294
+ this.binary = false;
2295
+ const welcome = await new Promise((resolve) => {
2296
+ const onMsg = (data) => {
2297
+ clearTimeout(timer);
2298
+ try {
2299
+ resolve(JSON.parse(data.toString()));
2300
+ }
2301
+ catch {
2302
+ resolve(null);
2303
+ }
2304
+ };
2305
+ // Only caps the wait when no Welcome comes (a silent/old server); the
2306
+ // listener resolves immediately when it does arrive. 2s comfortably exceeds
2307
+ // the handshake round-trip even on high-latency links.
2308
+ const timer = setTimeout(() => {
2309
+ socket.off("message", onMsg);
2310
+ resolve(null);
2311
+ }, 2000);
2312
+ socket.once("message", onMsg);
2313
+ try {
2314
+ socket.send(JSON.stringify({
2315
+ type: "Hello",
2316
+ payload: { formats: ["msgpack", "json"] },
2317
+ }));
2318
+ }
2319
+ catch {
2320
+ clearTimeout(timer);
2321
+ socket.off("message", onMsg);
2322
+ resolve(null);
2323
+ }
2324
+ });
2325
+ if (welcome &&
2326
+ welcome.type === "Welcome" &&
2327
+ welcome.payload?.format === "msgpack") {
2328
+ this.binary = true;
2329
+ }
2330
+ }
2331
+ /**
2332
+ * Send a request object on the active socket using the negotiated format:
2333
+ * binary msgpack when the server Welcomed it, JSON text otherwise. The single
2334
+ * write point so every request honors the negotiated transport.
2335
+ */
2336
+ sendFrame(obj) {
2337
+ this.ws.send(this.binary ? (0, msgpack_1.encode)(obj) : JSON.stringify(obj));
2338
+ }
2043
2339
  spawnDispatcher() {
2044
2340
  if (this.dispatcherRunning)
2045
2341
  return;
2046
2342
  this.dispatcherRunning = true;
2047
- this.ws.on("message", (data) => {
2343
+ // Capture the socket this dispatcher is bound to. After a reconnect, the old
2344
+ // socket may still emit late close/error events; ignore them so they don't
2345
+ // tear down the replacement connection.
2346
+ const socket = this.ws;
2347
+ socket.on("message", (data, isBinary) => {
2348
+ if (this.ws !== socket)
2349
+ return;
2048
2350
  try {
2049
- const msg = JSON.parse(data.toString());
2351
+ // A binary frame is msgpack (the server only sends binary once it has
2352
+ // Welcomed msgpack); a text frame is JSON. Decode by frame type so the
2353
+ // routed value is identical regardless of negotiated transport.
2354
+ const msg = isBinary
2355
+ ? (0, msgpack_1.decode)(data)
2356
+ : JSON.parse(data.toString());
2050
2357
  this.routeMessage(msg);
2051
2358
  }
2052
2359
  catch {
2053
2360
  // Ignore malformed messages
2054
2361
  }
2055
2362
  });
2056
- this.ws.on("close", () => {
2057
- this.dispatcherRunning = false;
2058
- // Notify all pending requests
2059
- for (const [, pending] of this.pendingRequests) {
2060
- pending.reject(new Error("WebSocket connection closed"));
2061
- }
2062
- this.pendingRequests.clear();
2063
- // Close all chat streams
2064
- for (const [, stream] of this.chatStreams) {
2065
- stream.emit("event", { type: "error", error: "Connection closed" });
2066
- stream.close();
2067
- }
2068
- this.chatStreams.clear();
2069
- // Close all subscriptions
2363
+ // Both "close" and "error" mean this socket is dead. ws typically emits
2364
+ // "error" followed by "close", so route both through one handler and let the
2365
+ // identity check dedupe: the first to fire nulls this.ws, the second no-ops.
2366
+ const onDown = () => {
2367
+ if (this.ws !== socket)
2368
+ return;
2369
+ this.handleDisconnect();
2370
+ };
2371
+ socket.on("close", onDown);
2372
+ socket.on("error", onDown);
2373
+ }
2374
+ /**
2375
+ * Reject in-flight requests and tear down the dead socket. If the close was
2376
+ * unexpected (not an explicit `close()`) and auto-reconnect is enabled,
2377
+ * schedule a reconnect that re-sends the active subscriptions.
2378
+ */
2379
+ handleDisconnect() {
2380
+ this.dispatcherRunning = false;
2381
+ this.ws = null;
2382
+ // Reject all in-flight pending requests so callers don't hang forever.
2383
+ for (const [, pending] of this.pendingRequests) {
2384
+ if (pending.timer)
2385
+ clearTimeout(pending.timer);
2386
+ pending.reject(new Error("WebSocket connection closed"));
2387
+ }
2388
+ this.pendingRequests.clear();
2389
+ if (this.registerToolsAck) {
2390
+ this.registerToolsAck.reject(new Error("WebSocket connection closed"));
2391
+ this.registerToolsAck = null;
2392
+ }
2393
+ // Close all chat streams (they are one-shot; not replayed on reconnect).
2394
+ for (const [, stream] of this.chatStreams) {
2395
+ stream.emit("event", { type: "error", error: "Connection closed" });
2396
+ stream.close();
2397
+ }
2398
+ this.chatStreams.clear();
2399
+ const shouldReconnect = this.autoReconnect && !this.closed && this.subscriptionParams.size > 0;
2400
+ if (shouldReconnect) {
2401
+ this.scheduleReconnect();
2402
+ }
2403
+ else {
2404
+ // No reconnect: tear down subscriptions too.
2070
2405
  for (const [, stream] of this.subscriptions) {
2071
2406
  stream.close();
2072
2407
  }
2073
2408
  this.subscriptions.clear();
2074
- this.ws = null;
2075
- });
2409
+ this.subscriptionParams.clear();
2410
+ }
2411
+ }
2412
+ /**
2413
+ * Reconnect with capped exponential backoff + jitter, then re-send the
2414
+ * subscribe messages for every active subscription so the SAME EventStream
2415
+ * keeps delivering mutations after a transient drop.
2416
+ */
2417
+ scheduleReconnect() {
2418
+ if (this.reconnecting)
2419
+ return;
2420
+ this.reconnecting = true;
2421
+ const attempt = async () => {
2422
+ // Bail if the client was closed, or if every subscription was torn down
2423
+ // (e.g. unsubscribed) while a reconnect was in-flight — reconnect was only
2424
+ // opted into because subscriptions existed, so there's nothing to restore.
2425
+ if (this.closed || this.subscriptionParams.size === 0) {
2426
+ this.reconnecting = false;
2427
+ return;
2428
+ }
2429
+ if (this.reconnectMaxAttempts > 0 &&
2430
+ this.reconnectAttempts >= this.reconnectMaxAttempts) {
2431
+ // Give up: tear down subscriptions and notify consumers.
2432
+ this.reconnecting = false;
2433
+ for (const [, stream] of this.subscriptions) {
2434
+ stream.emit("error", "WebSocket reconnect failed");
2435
+ stream.close();
2436
+ }
2437
+ this.subscriptions.clear();
2438
+ this.subscriptionParams.clear();
2439
+ return;
2440
+ }
2441
+ const delay = this.computeBackoff(this.reconnectAttempts);
2442
+ this.reconnectAttempts++;
2443
+ await new Promise((r) => setTimeout(r, delay));
2444
+ // Re-check after the backoff delay: close() or a full unsubscribe may have
2445
+ // happened while we were waiting, in which case skip reopening the socket.
2446
+ if (this.closed || this.subscriptionParams.size === 0) {
2447
+ this.reconnecting = false;
2448
+ return;
2449
+ }
2450
+ try {
2451
+ // Route through ensureConnected() so a request-driven connect and this
2452
+ // reconnect share one in-flight connectPromise/socket — opening two live
2453
+ // sockets would misroute responses.
2454
+ await this.ensureConnected();
2455
+ // close() may have been called while the connect was in-flight; if so,
2456
+ // tear down the freshly-opened socket instead of leaving it orphaned.
2457
+ if (this.closed) {
2458
+ try {
2459
+ this.ws?.close?.();
2460
+ }
2461
+ catch {
2462
+ /* already closing */
2463
+ }
2464
+ this.ws = null;
2465
+ this.dispatcherRunning = false;
2466
+ this.reconnecting = false;
2467
+ return;
2468
+ }
2469
+ // Success — reset backoff and replay subscriptions.
2470
+ this.reconnectAttempts = 0;
2471
+ this.reconnecting = false;
2472
+ await this.resubscribeAll();
2473
+ }
2474
+ catch {
2475
+ // Connect failed — schedule the next attempt WITHOUT recursive await so
2476
+ // a prolonged outage can't build an unbounded promise chain.
2477
+ setTimeout(() => void attempt(), 0);
2478
+ }
2479
+ };
2480
+ void attempt();
2481
+ }
2482
+ /** Re-send Subscribe frames for every tracked subscription after a reconnect. */
2483
+ async resubscribeAll() {
2484
+ for (const [collection, options] of this.subscriptionParams) {
2485
+ const stream = this.subscriptions.get(collection);
2486
+ if (!stream || stream.closed)
2487
+ continue;
2488
+ const messageId = this.genMessageId();
2489
+ const request = {
2490
+ type: "Subscribe",
2491
+ messageId,
2492
+ payload: {
2493
+ collection,
2494
+ ...(options?.filterField && { filter_field: options.filterField }),
2495
+ ...(options?.filterValue && { filter_value: options.filterValue }),
2496
+ },
2497
+ };
2498
+ try {
2499
+ await this.sendRequest(request);
2500
+ }
2501
+ catch {
2502
+ // If the re-subscribe ack fails, leave it tracked; the next
2503
+ // disconnect/reconnect cycle will attempt it again.
2504
+ }
2505
+ }
2076
2506
  }
2077
2507
  routeMessage(msg) {
2078
2508
  switch (msg.type) {
@@ -2085,15 +2515,7 @@ class WebSocketClient {
2085
2515
  msg.payload?.messageId;
2086
2516
  let matched = false;
2087
2517
  if (messageId && this.pendingRequests.has(messageId)) {
2088
- const pending = this.pendingRequests.get(messageId);
2089
- this.pendingRequests.delete(messageId);
2090
- if (msg.type === "Error") {
2091
- pending.reject(new Error(msg.message || "Unknown error"));
2092
- }
2093
- else {
2094
- pending.resolve(msg.payload);
2095
- }
2096
- matched = true;
2518
+ matched = this.settlePending(messageId, msg.type === "Error", msg);
2097
2519
  }
2098
2520
  if (!matched && this.registerToolsAck) {
2099
2521
  const ack = this.registerToolsAck;
@@ -2106,19 +2528,14 @@ class WebSocketClient {
2106
2528
  }
2107
2529
  matched = true;
2108
2530
  }
2109
- // Server doesn't echo messageId — if there's exactly one pending
2531
+ // Server doesn't echo messageId at all — if there's exactly one pending
2110
2532
  // request, deliver the response to it (sequential request/response).
2111
- if (!matched && this.pendingRequests.size === 1) {
2112
- const entry = this.pendingRequests.entries().next().value;
2113
- const key = entry[0];
2114
- const pending = entry[1];
2115
- this.pendingRequests.delete(key);
2116
- if (msg.type === "Error") {
2117
- pending.reject(new Error(msg.message || "Unknown error"));
2118
- }
2119
- else {
2120
- pending.resolve(msg.payload);
2121
- }
2533
+ // Only when messageId is absent: a present-but-unmatched id means a late
2534
+ // response for an already-settled/timed-out request, which must NOT be
2535
+ // misrouted to whatever request happens to still be pending.
2536
+ if (!matched && !messageId && this.pendingRequests.size === 1) {
2537
+ const key = this.pendingRequests.keys().next().value;
2538
+ this.settlePending(key, msg.type === "Error", msg);
2122
2539
  }
2123
2540
  break;
2124
2541
  }
@@ -2205,16 +2622,46 @@ class WebSocketClient {
2205
2622
  await this.ensureConnected();
2206
2623
  const messageId = request.messageId || request.message_id;
2207
2624
  return new Promise((resolve, reject) => {
2208
- this.pendingRequests.set(messageId, { resolve, reject });
2625
+ // Per-request timeout: reject if no response arrives in the window so a
2626
+ // dropped/never-answered response can't leave the promise pending forever.
2627
+ let timer;
2628
+ if (this.requestTimeoutMs > 0) {
2629
+ timer = setTimeout(() => {
2630
+ if (this.pendingRequests.delete(messageId)) {
2631
+ reject(new Error(`WebSocket request "${request.type}" timed out after ${this.requestTimeoutMs}ms`));
2632
+ }
2633
+ }, this.requestTimeoutMs);
2634
+ // Don't keep the process alive just for this timer.
2635
+ timer?.unref?.();
2636
+ }
2637
+ this.pendingRequests.set(messageId, { resolve, reject, timer });
2209
2638
  try {
2210
- this.ws.send(JSON.stringify(request));
2639
+ this.sendFrame(request);
2211
2640
  }
2212
2641
  catch (err) {
2213
2642
  this.pendingRequests.delete(messageId);
2643
+ if (timer)
2644
+ clearTimeout(timer);
2214
2645
  reject(err);
2215
2646
  }
2216
2647
  });
2217
2648
  }
2649
+ /** Resolve/reject a pending request, clearing its timeout timer. */
2650
+ settlePending(messageId, isError, msg) {
2651
+ const pending = this.pendingRequests.get(messageId);
2652
+ if (!pending)
2653
+ return false;
2654
+ this.pendingRequests.delete(messageId);
2655
+ if (pending.timer)
2656
+ clearTimeout(pending.timer);
2657
+ if (isError) {
2658
+ pending.reject(new Error(msg.message || "Unknown error"));
2659
+ }
2660
+ else {
2661
+ pending.resolve(msg.payload);
2662
+ }
2663
+ return true;
2664
+ }
2218
2665
  /**
2219
2666
  * Find all records in a collection via WebSocket.
2220
2667
  */
@@ -2239,6 +2686,8 @@ class WebSocketClient {
2239
2686
  const messageId = this.genMessageId();
2240
2687
  const stream = new EventStream();
2241
2688
  this.subscriptions.set(collection, stream);
2689
+ // Track params so the subscription can be replayed on reconnect.
2690
+ this.subscriptionParams.set(collection, options);
2242
2691
  const request = {
2243
2692
  type: "Subscribe",
2244
2693
  messageId,
@@ -2254,10 +2703,44 @@ class WebSocketClient {
2254
2703
  }
2255
2704
  catch (err) {
2256
2705
  this.subscriptions.delete(collection);
2706
+ this.subscriptionParams.delete(collection);
2257
2707
  throw err;
2258
2708
  }
2259
2709
  return stream;
2260
2710
  }
2711
+ /**
2712
+ * Unsubscribe from a collection's mutation notifications. This is an
2713
+ * intentional teardown, so the subscription is NOT replayed on reconnect.
2714
+ */
2715
+ unsubscribe(collection) {
2716
+ const stream = this.subscriptions.get(collection);
2717
+ this.subscriptions.delete(collection);
2718
+ this.subscriptionParams.delete(collection);
2719
+ if (stream && !stream.closed) {
2720
+ stream.close();
2721
+ }
2722
+ // Best-effort: tell the server to stop streaming this collection (the
2723
+ // server already handles an Unsubscribe frame). If the socket isn't open
2724
+ // the local teardown above suffices, since the server drops subscriptions
2725
+ // when the connection closes. A unique messageId is attached so the
2726
+ // server's Success ack carries a correlation id: it has no pending request
2727
+ // to match, so it is simply ignored — and because the id is present, the
2728
+ // single-pending fallback can't misroute it to an unrelated request.
2729
+ if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
2730
+ try {
2731
+ this.sendFrame({
2732
+ type: "Unsubscribe",
2733
+ messageId: this.genMessageId(),
2734
+ payload: { collection },
2735
+ });
2736
+ }
2737
+ catch {
2738
+ // Best-effort: the socket can close between the readyState check and the
2739
+ // send. Local teardown already happened, so swallow the failure rather
2740
+ // than throw out of a void teardown call.
2741
+ }
2742
+ }
2743
+ }
2261
2744
  /**
2262
2745
  * Send a chat message and receive a streaming response.
2263
2746
  * Returns an EventStream that emits "event" with ChatStreamEvent objects.
@@ -2285,7 +2768,7 @@ class WebSocketClient {
2285
2768
  ...(options?.excludeTools && { exclude_tools: options.excludeTools }),
2286
2769
  },
2287
2770
  };
2288
- this.ws.send(JSON.stringify(request));
2771
+ this.sendFrame(request);
2289
2772
  return stream;
2290
2773
  }
2291
2774
  /**
@@ -2305,7 +2788,7 @@ class WebSocketClient {
2305
2788
  resolve: () => resolve(),
2306
2789
  reject: (err) => reject(err),
2307
2790
  };
2308
- this.ws.send(JSON.stringify(request));
2791
+ this.sendFrame(request);
2309
2792
  });
2310
2793
  }
2311
2794
  /**
@@ -2323,7 +2806,24 @@ class WebSocketClient {
2323
2806
  ...(error !== undefined && { error }),
2324
2807
  },
2325
2808
  };
2326
- this.ws.send(JSON.stringify(request));
2809
+ this.sendFrame(request);
2810
+ }
2811
+ /**
2812
+ * Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
2813
+ * stop generating tokens for the given chat.
2814
+ */
2815
+ async cancelChat(chatId) {
2816
+ await this.ensureConnected();
2817
+ // Attach a unique messageId (same generator as unsubscribe). Any Success ack
2818
+ // from the server then carries a correlation id: it has no pending request to
2819
+ // match, so it is ignored — and because the id is present, the dispatcher's
2820
+ // single-pending fallback can't misroute the ack to an unrelated request.
2821
+ const request = {
2822
+ type: "CancelChat",
2823
+ messageId: this.genMessageId(),
2824
+ payload: { chat_id: chatId },
2825
+ };
2826
+ this.sendFrame(request);
2327
2827
  }
2328
2828
  /**
2329
2829
  * Stateless raw LLM completion via WebSocket.
@@ -2481,8 +2981,42 @@ class WebSocketClient {
2481
2981
  }
2482
2982
  /**
2483
2983
  * Close the WebSocket connection.
2984
+ *
2985
+ * This is an INTENTIONAL close: it disables auto-reconnect, rejects any
2986
+ * in-flight requests, and tears down all subscriptions/chat streams so
2987
+ * nothing is replayed afterward.
2484
2988
  */
2485
2989
  close() {
2990
+ // Mark intentional so the close handler doesn't trigger a reconnect.
2991
+ this.closed = true;
2992
+ this.reconnecting = false;
2993
+ // Reject any in-flight requests and clear their timers.
2994
+ for (const [, pending] of this.pendingRequests) {
2995
+ if (pending.timer)
2996
+ clearTimeout(pending.timer);
2997
+ pending.reject(new Error("WebSocket connection closed"));
2998
+ }
2999
+ this.pendingRequests.clear();
3000
+ // Tear down subscriptions + their replay bookkeeping.
3001
+ for (const [, stream] of this.subscriptions) {
3002
+ if (!stream.closed)
3003
+ stream.close();
3004
+ }
3005
+ this.subscriptions.clear();
3006
+ this.subscriptionParams.clear();
3007
+ // Reject any in-flight tool registration ack. Done here (not just in the
3008
+ // ws "close" handler) so it's cleaned up even when this.ws is already null.
3009
+ if (this.registerToolsAck) {
3010
+ this.registerToolsAck.reject(new Error("WebSocket connection closed"));
3011
+ this.registerToolsAck = null;
3012
+ }
3013
+ // Tear down chat streams immediately; they are one-shot and not replayed,
3014
+ // and we can't rely on the underlying ws "close" event having fired.
3015
+ for (const [, stream] of this.chatStreams) {
3016
+ stream.emit("event", { type: "error", error: "Connection closed" });
3017
+ stream.close();
3018
+ }
3019
+ this.chatStreams.clear();
2486
3020
  if (this.ws) {
2487
3021
  this.ws.close();
2488
3022
  this.ws = null;