@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/README.md +28 -1
- package/dist/client.d.ts +198 -17
- package/dist/client.js +653 -119
- package/dist/client.test.js +287 -0
- package/dist/functions.test.d.ts +1 -2
- package/dist/functions.test.js +1 -2
- package/dist/query-builder.d.ts +0 -4
- package/dist/query-builder.js +2 -14
- package/dist/query-builder.test.js +0 -5
- package/dist/utils.js +7 -1
- package/dist/utils.test.js +4 -0
- package/dist/websocket.test.js +339 -5
- package/package.json +1 -1
- package/src/client.test.ts +394 -1
- package/src/client.ts +821 -130
- package/src/functions.test.ts +1 -2
- package/src/query-builder.test.ts +0 -7
- package/src/query-builder.ts +2 -14
- package/src/utils.test.ts +5 -0
- package/src/utils.ts +9 -1
- package/src/websocket.test.ts +498 -5
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 =
|
|
644
|
-
response.headers.get("retry-after")
|
|
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
|
-
|
|
650
|
-
|
|
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 =
|
|
748
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
678
749
|
console.log(
|
|
679
|
-
`Service unavailable. Retrying after ${retryDelay}
|
|
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 =
|
|
696
|
-
console.log(
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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(
|
|
774
|
-
|
|
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:
|
|
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
|
-
}>(
|
|
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
|
|
1855
|
-
|
|
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)
|
|
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>(
|
|
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>(
|
|
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>(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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
|
-
*
|
|
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 ${
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
this.pendingRequests.
|
|
3452
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|