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