doover-js 0.6.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/doover-auth.d.ts +6 -0
- package/dist/auth/doover-auth.js +8 -0
- package/dist/auth/doover-token-auth.d.ts +3 -0
- package/dist/auth/doover-token-auth.js +41 -3
- package/dist/client/multiplex-client.js +17 -1
- package/dist/gateway/gateway-client.d.ts +1 -0
- package/dist/gateway/gateway-client.js +12 -5
- package/dist/http/rest-client.d.ts +3 -0
- package/dist/http/rest-client.js +48 -28
- package/dist/react/messageRangeStore.d.ts +87 -0
- package/dist/react/messageRangeStore.js +134 -0
- package/dist/react/useChannelMessages.d.ts +21 -1
- package/dist/react/useChannelMessages.js +54 -5
- package/dist/react/useMultiAgentChannelMessages.d.ts +25 -1
- package/dist/react/useMultiAgentChannelMessages.js +65 -5
- package/dist/test/gateway-client.test.js +43 -0
- package/dist/test/message-range-store.test.d.ts +1 -0
- package/dist/test/message-range-store.test.js +167 -0
- package/dist/test/react-channel-messages-range.test.d.ts +1 -0
- package/dist/test/react-channel-messages-range.test.js +109 -0
- package/dist/test/rest-client.test.js +65 -0
- package/package.json +1 -1
|
@@ -41,6 +41,12 @@ export declare abstract class DooverAuth {
|
|
|
41
41
|
* Called before every HTTP request and before opening a websocket.
|
|
42
42
|
*/
|
|
43
43
|
abstract ensureReady(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Called after an authenticated HTTP request receives a 401. Return true when
|
|
46
|
+
* the auth layer recovered (for example by forcing a token refresh) and the
|
|
47
|
+
* request should be retried once.
|
|
48
|
+
*/
|
|
49
|
+
handleUnauthorized(): Promise<boolean>;
|
|
44
50
|
/** Attach a profile and optional config manager for refresh persistence. */
|
|
45
51
|
attachProfile(profile: AuthProfile, configManager?: AuthProfileStore): void;
|
|
46
52
|
}
|
package/dist/auth/doover-auth.js
CHANGED
|
@@ -13,6 +13,14 @@ class DooverAuth {
|
|
|
13
13
|
this.profile = null;
|
|
14
14
|
this.configManager = null;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Called after an authenticated HTTP request receives a 401. Return true when
|
|
18
|
+
* the auth layer recovered (for example by forcing a token refresh) and the
|
|
19
|
+
* request should be retried once.
|
|
20
|
+
*/
|
|
21
|
+
async handleUnauthorized() {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
16
24
|
/** Attach a profile and optional config manager for refresh persistence. */
|
|
17
25
|
attachProfile(profile, configManager) {
|
|
18
26
|
this.profile = profile;
|
|
@@ -10,6 +10,7 @@ export declare class DooverTokenAuth extends DooverAuth {
|
|
|
10
10
|
private authServerClientId;
|
|
11
11
|
private fetchImpl;
|
|
12
12
|
private refreshInFlight;
|
|
13
|
+
private authInvalid;
|
|
13
14
|
constructor(options: {
|
|
14
15
|
token?: string | null;
|
|
15
16
|
tokenExpires?: Date | number | null;
|
|
@@ -28,9 +29,11 @@ export declare class DooverTokenAuth extends DooverAuth {
|
|
|
28
29
|
setToken(token: string | null, tokenExpires?: Date | number | null): void;
|
|
29
30
|
setRefreshToken(refreshToken: string | null): void;
|
|
30
31
|
ensureReady(): Promise<void>;
|
|
32
|
+
handleUnauthorized(): Promise<boolean>;
|
|
31
33
|
attachProfile(profile: AuthProfile, configManager?: AuthProfileStore): void;
|
|
32
34
|
private needsRefresh;
|
|
33
35
|
private refresh;
|
|
34
36
|
private persistToProfile;
|
|
37
|
+
private markAuthInvalid;
|
|
35
38
|
private resolveExpiry;
|
|
36
39
|
}
|
|
@@ -10,6 +10,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
10
10
|
constructor(options) {
|
|
11
11
|
super();
|
|
12
12
|
this.refreshInFlight = null;
|
|
13
|
+
this.authInvalid = false;
|
|
13
14
|
this.token = options.token ?? null;
|
|
14
15
|
this.tokenExpires = this.resolveExpiry(options.tokenExpires, options.token ?? null);
|
|
15
16
|
this.refreshToken = options.refreshToken ?? null;
|
|
@@ -48,13 +49,22 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
48
49
|
setToken(token, tokenExpires) {
|
|
49
50
|
this.token = token;
|
|
50
51
|
this.tokenExpires = this.resolveExpiry(tokenExpires, token);
|
|
52
|
+
if (token) {
|
|
53
|
+
this.authInvalid = false;
|
|
54
|
+
}
|
|
51
55
|
this.persistToProfile();
|
|
52
56
|
}
|
|
53
57
|
setRefreshToken(refreshToken) {
|
|
54
58
|
this.refreshToken = refreshToken;
|
|
59
|
+
if (refreshToken) {
|
|
60
|
+
this.authInvalid = false;
|
|
61
|
+
}
|
|
55
62
|
this.persistToProfile();
|
|
56
63
|
}
|
|
57
64
|
async ensureReady() {
|
|
65
|
+
if (this.authInvalid) {
|
|
66
|
+
throw new errors_1.DooverAuthError("Authentication is invalid; sign in again");
|
|
67
|
+
}
|
|
58
68
|
if (!this.needsRefresh()) {
|
|
59
69
|
return;
|
|
60
70
|
}
|
|
@@ -66,6 +76,23 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
66
76
|
}
|
|
67
77
|
return this.refreshInFlight;
|
|
68
78
|
}
|
|
79
|
+
async handleUnauthorized() {
|
|
80
|
+
if (this.authInvalid) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (!this.refreshInFlight) {
|
|
84
|
+
this.refreshInFlight = this.refresh().finally(() => {
|
|
85
|
+
this.refreshInFlight = null;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await this.refreshInFlight;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
69
96
|
// ------------------------------------------------------------------
|
|
70
97
|
// Profile attachment override — load refresh metadata from profile
|
|
71
98
|
// ------------------------------------------------------------------
|
|
@@ -110,6 +137,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
110
137
|
if (!this.authServerUrl ||
|
|
111
138
|
!this.refreshToken ||
|
|
112
139
|
!this.authServerClientId) {
|
|
140
|
+
this.markAuthInvalid();
|
|
113
141
|
throw new errors_1.DooverAuthError("Cannot refresh token: missing authServerUrl, refreshToken, or authServerClientId");
|
|
114
142
|
}
|
|
115
143
|
const url = new URL(`${this.authServerUrl}/oauth2/token`);
|
|
@@ -129,12 +157,17 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
129
157
|
throw new errors_1.DooverAuthError(`Token refresh request failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
130
158
|
}
|
|
131
159
|
if (!response.ok) {
|
|
160
|
+
if (response.status >= 400 && response.status < 500) {
|
|
161
|
+
this.markAuthInvalid();
|
|
162
|
+
}
|
|
132
163
|
throw new errors_1.DooverAuthError(`Token refresh failed with status ${response.status}`);
|
|
133
164
|
}
|
|
134
165
|
const body = (await response.json());
|
|
135
166
|
if (!body.access_token) {
|
|
167
|
+
this.markAuthInvalid();
|
|
136
168
|
throw new errors_1.DooverAuthError("Token refresh response did not contain an access_token");
|
|
137
169
|
}
|
|
170
|
+
this.authInvalid = false;
|
|
138
171
|
this.token = body.access_token;
|
|
139
172
|
this.tokenExpires =
|
|
140
173
|
typeof body.expires_in === "number"
|
|
@@ -154,9 +187,7 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
154
187
|
this.profile.tokenExpires = this.tokenExpires
|
|
155
188
|
? this.tokenExpires.toISOString()
|
|
156
189
|
: null;
|
|
157
|
-
|
|
158
|
-
this.profile.refreshToken = this.refreshToken;
|
|
159
|
-
}
|
|
190
|
+
this.profile.refreshToken = this.refreshToken;
|
|
160
191
|
if (this.configManager) {
|
|
161
192
|
try {
|
|
162
193
|
this.configManager.create(this.profile);
|
|
@@ -167,6 +198,13 @@ class DooverTokenAuth extends doover_auth_1.DooverAuth {
|
|
|
167
198
|
}
|
|
168
199
|
}
|
|
169
200
|
}
|
|
201
|
+
markAuthInvalid() {
|
|
202
|
+
this.authInvalid = true;
|
|
203
|
+
this.token = null;
|
|
204
|
+
this.tokenExpires = null;
|
|
205
|
+
this.refreshToken = null;
|
|
206
|
+
this.persistToProfile();
|
|
207
|
+
}
|
|
170
208
|
resolveExpiry(explicit, token) {
|
|
171
209
|
if (explicit instanceof Date) {
|
|
172
210
|
return explicit;
|
|
@@ -554,7 +554,23 @@ class MultiplexClient {
|
|
|
554
554
|
}
|
|
555
555
|
const responses = await Promise.all(members.map((m) => m.client.agents.getMultiAgentMessages(...args)));
|
|
556
556
|
const merged = (0, multiplex_merge_1.mergeMessages)(responses.map((r) => r.results), { order: "desc" });
|
|
557
|
-
|
|
557
|
+
// Union each member's per-agent resume cursors so a paginating caller
|
|
558
|
+
// keeps walking. If one agent lives in two members with different
|
|
559
|
+
// cursors, keep the lexically-highest (newest) — resuming from the
|
|
560
|
+
// older one would skip the newer member's tail; resuming from the
|
|
561
|
+
// newer just re-fetches a few messages that dedupe by id downstream.
|
|
562
|
+
const nextCursors = {};
|
|
563
|
+
for (const r of responses) {
|
|
564
|
+
for (const [id, cursor] of Object.entries(r.next_cursors ?? {})) {
|
|
565
|
+
if (!(id in nextCursors) || cursor > nextCursors[id])
|
|
566
|
+
nextCursors[id] = cursor;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
results: merged,
|
|
571
|
+
count: merged.length,
|
|
572
|
+
...(Object.keys(nextCursors).length > 0 ? { next_cursors: nextCursors } : {}),
|
|
573
|
+
};
|
|
558
574
|
},
|
|
559
575
|
async getMultiAgentAggregates(channelName, ...rest) {
|
|
560
576
|
const { args, sources } = self.splitSourcesOption([channelName, ...rest]);
|
|
@@ -71,6 +71,7 @@ export declare class GatewayClient {
|
|
|
71
71
|
private send;
|
|
72
72
|
private emit;
|
|
73
73
|
private scheduleReconnect;
|
|
74
|
+
private connectInBackground;
|
|
74
75
|
/** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
|
|
75
76
|
private computeReconnectDelay;
|
|
76
77
|
private installLifecycleListeners;
|
|
@@ -142,7 +142,7 @@ class GatewayClient {
|
|
|
142
142
|
diff_only: options?.diff_only ?? false,
|
|
143
143
|
});
|
|
144
144
|
if (!this.isConnected()) {
|
|
145
|
-
|
|
145
|
+
this.connectInBackground();
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
148
|
this.send({
|
|
@@ -222,7 +222,7 @@ class GatewayClient {
|
|
|
222
222
|
}
|
|
223
223
|
syncChannel(channel) {
|
|
224
224
|
if (!this.isConnected()) {
|
|
225
|
-
|
|
225
|
+
this.connectInBackground();
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
228
228
|
this.send({
|
|
@@ -235,7 +235,7 @@ class GatewayClient {
|
|
|
235
235
|
}
|
|
236
236
|
sendOneShotMessage(channel, data) {
|
|
237
237
|
if (!this.isConnected()) {
|
|
238
|
-
|
|
238
|
+
this.connectInBackground();
|
|
239
239
|
return;
|
|
240
240
|
}
|
|
241
241
|
this.send({
|
|
@@ -382,9 +382,16 @@ class GatewayClient {
|
|
|
382
382
|
this.reconnectAttempts += 1;
|
|
383
383
|
this.reconnectTimer = setTimeout(() => {
|
|
384
384
|
this.reconnectTimer = null;
|
|
385
|
-
|
|
385
|
+
this.connectInBackground();
|
|
386
386
|
}, delay);
|
|
387
387
|
}
|
|
388
|
+
connectInBackground() {
|
|
389
|
+
void this.connect().catch((err) => {
|
|
390
|
+
this.emit("wssError", {
|
|
391
|
+
message: err instanceof Error ? err.message : String(err),
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
388
395
|
/** Exponential backoff with full-jitter, capped at RECONNECT_CAP_MS. */
|
|
389
396
|
computeReconnectDelay() {
|
|
390
397
|
const exp = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempts);
|
|
@@ -413,7 +420,7 @@ class GatewayClient {
|
|
|
413
420
|
clearTimeout(this.reconnectTimer);
|
|
414
421
|
this.reconnectTimer = null;
|
|
415
422
|
}
|
|
416
|
-
|
|
423
|
+
this.connectInBackground();
|
|
417
424
|
}
|
|
418
425
|
channelKey(channel) {
|
|
419
426
|
return `${channel.agent_id}/${channel.name}`;
|
|
@@ -46,6 +46,9 @@ export declare class RestClient {
|
|
|
46
46
|
setStats(stats: DooverStatsCollector | null): void;
|
|
47
47
|
request<T>(options: RequestOptions): Promise<T>;
|
|
48
48
|
private doRequest;
|
|
49
|
+
private prepareAuth;
|
|
50
|
+
private prepareRequest;
|
|
51
|
+
private sendRequest;
|
|
49
52
|
get<T>(path: string, query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
|
|
50
53
|
post<T>(path: string, body?: RequestOptions["body"], query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
|
|
51
54
|
put<T>(path: string, body?: RequestOptions["body"], query?: RequestOptions["query"], baseUrl?: string): Promise<T>;
|
package/dist/http/rest-client.js
CHANGED
|
@@ -30,20 +30,45 @@ class RestClient {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async doRequest(options) {
|
|
33
|
+
await this.prepareAuth();
|
|
34
|
+
const request = this.prepareRequest(options);
|
|
35
|
+
let response = await this.sendRequest(request);
|
|
36
|
+
let payload = await this.parseResponse(response);
|
|
37
|
+
if (response.status === 401 && this.auth) {
|
|
38
|
+
const recovered = await this.auth.handleUnauthorized();
|
|
39
|
+
if (recovered) {
|
|
40
|
+
await this.prepareAuth();
|
|
41
|
+
const retryRequest = this.prepareRequest(options);
|
|
42
|
+
response = await this.sendRequest(retryRequest);
|
|
43
|
+
payload = await this.parseResponse(response);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new errors_1.DooverApiError({
|
|
48
|
+
status: response.status,
|
|
49
|
+
body: payload,
|
|
50
|
+
url: request.url,
|
|
51
|
+
method: request.method,
|
|
52
|
+
message: typeof payload === "object" &&
|
|
53
|
+
payload !== null &&
|
|
54
|
+
"message" in payload &&
|
|
55
|
+
typeof payload.message === "string"
|
|
56
|
+
? payload.message
|
|
57
|
+
: undefined,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return payload;
|
|
61
|
+
}
|
|
62
|
+
async prepareAuth() {
|
|
33
63
|
if (this.auth) {
|
|
34
64
|
await this.auth.ensureReady();
|
|
35
65
|
}
|
|
66
|
+
}
|
|
67
|
+
prepareRequest(options) {
|
|
36
68
|
const method = options.method ?? "GET";
|
|
37
69
|
const url = this.buildUrl(options.baseUrl ?? this.config.dataRestUrl, options.path, options.query);
|
|
38
70
|
const headers = new Headers(options.headers);
|
|
39
71
|
const body = this.normalizeBody(options.body, headers);
|
|
40
|
-
// Merge auth headers.
|
|
41
|
-
if (this.auth) {
|
|
42
|
-
const authHeaders = await this.auth.getHttpHeaders();
|
|
43
|
-
for (const [key, value] of Object.entries(authHeaders)) {
|
|
44
|
-
headers.set(key, value);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
72
|
if (!options.omitSharingHeader) {
|
|
48
73
|
headers.set("X-Doover-Sharing", this.config.sharing);
|
|
49
74
|
}
|
|
@@ -57,29 +82,24 @@ class RestClient {
|
|
|
57
82
|
const credentials = this.auth
|
|
58
83
|
? this.auth.getFetchCredentials()
|
|
59
84
|
: "include";
|
|
85
|
+
return { url, method, headers, body, credentials };
|
|
86
|
+
}
|
|
87
|
+
async sendRequest(request) {
|
|
88
|
+
// Merge auth headers immediately before fetch so a retry can use refreshed
|
|
89
|
+
// credentials without mutating the first request's Headers instance.
|
|
90
|
+
if (this.auth) {
|
|
91
|
+
const authHeaders = await this.auth.getHttpHeaders();
|
|
92
|
+
for (const [key, value] of Object.entries(authHeaders)) {
|
|
93
|
+
request.headers.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
60
96
|
const fetchImpl = this.config.fetchImpl ?? fetch;
|
|
61
|
-
|
|
62
|
-
method,
|
|
63
|
-
headers,
|
|
64
|
-
body,
|
|
65
|
-
credentials,
|
|
97
|
+
return fetchImpl(request.url, {
|
|
98
|
+
method: request.method,
|
|
99
|
+
headers: request.headers,
|
|
100
|
+
body: request.body,
|
|
101
|
+
credentials: request.credentials,
|
|
66
102
|
});
|
|
67
|
-
const payload = await this.parseResponse(response);
|
|
68
|
-
if (!response.ok) {
|
|
69
|
-
throw new errors_1.DooverApiError({
|
|
70
|
-
status: response.status,
|
|
71
|
-
body: payload,
|
|
72
|
-
url,
|
|
73
|
-
method,
|
|
74
|
-
message: typeof payload === "object" &&
|
|
75
|
-
payload !== null &&
|
|
76
|
-
"message" in payload &&
|
|
77
|
-
typeof payload.message === "string"
|
|
78
|
-
? payload.message
|
|
79
|
-
: undefined,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
return payload;
|
|
83
103
|
}
|
|
84
104
|
get(path, query, baseUrl) {
|
|
85
105
|
return this.request({ path, query, baseUrl });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { MessageStructure } from "../types/common";
|
|
2
|
+
/**
|
|
3
|
+
* A per-channel cache of *proven-complete* id ranges.
|
|
4
|
+
*
|
|
5
|
+
* `useChannelMessages` models history as a chain of cursors: each page's oldest
|
|
6
|
+
* id is the next page's `before`. That chain is anchored at its first cursor, so
|
|
7
|
+
* two windows over the same channel are two unrelated chains — re-anchoring
|
|
8
|
+
* always refetches, even over data already held.
|
|
9
|
+
*
|
|
10
|
+
* This store keeps the fact the chain throws away. A response to
|
|
11
|
+
* `before=X, limit=N` is "the N newest messages strictly older than X", so a
|
|
12
|
+
* full page proves that *every* message in `[oldest.id, X)` is in hand. History
|
|
13
|
+
* is append-only, so that stays true forever. Overlapping windows then merge
|
|
14
|
+
* into one segment instead of duplicating, and revisiting a range costs nothing.
|
|
15
|
+
*
|
|
16
|
+
* The store sits under the query, not beside it: the chains stay as they are and
|
|
17
|
+
* remain the cache React Query sees, while this layer removes the network calls
|
|
18
|
+
* underneath them.
|
|
19
|
+
*
|
|
20
|
+
* Ids are decimal snowflakes of varying length, so every comparison goes through
|
|
21
|
+
* `BigInt` — string ordering would put "500" after "4000".
|
|
22
|
+
*/
|
|
23
|
+
/** A contiguous range of history we can prove we hold in full. */
|
|
24
|
+
export interface RangeSegment {
|
|
25
|
+
/** Id of the oldest message held. Meaningless when `atStart` (coverage runs to -inf). */
|
|
26
|
+
lo: bigint;
|
|
27
|
+
/** Exclusive upper bound of proven coverage. */
|
|
28
|
+
hi: bigint;
|
|
29
|
+
/** No messages exist below `lo` — the channel (or its retention window) starts here. */
|
|
30
|
+
atStart: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* `hi` tracks the live feed. Only true while a fetch has proven coverage up to
|
|
33
|
+
* roughly now AND the socket has not dropped since — see `sealTips`.
|
|
34
|
+
*/
|
|
35
|
+
live: boolean;
|
|
36
|
+
/** Ascending by id. */
|
|
37
|
+
messages: MessageStructure[];
|
|
38
|
+
}
|
|
39
|
+
export declare class ChannelRangeStore {
|
|
40
|
+
/** Disjoint, ascending by `lo`. */
|
|
41
|
+
private segments;
|
|
42
|
+
/**
|
|
43
|
+
* Answer "the newest `limit` messages strictly older than `before`" from cache,
|
|
44
|
+
* or `undefined` when the range isn't proven and the caller must fetch.
|
|
45
|
+
*
|
|
46
|
+
* Returns ascending by id, matching `listMessages({ order: "asc" })`.
|
|
47
|
+
*/
|
|
48
|
+
read(before: bigint, limit: number): MessageStructure[] | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Record a fetched page. `before` is the cursor actually sent and `limit` the
|
|
51
|
+
* one actually requested — a short page proves the range bottoms out, which is
|
|
52
|
+
* only sound because the server rejects an over-cap `limit` rather than
|
|
53
|
+
* silently clamping it.
|
|
54
|
+
*/
|
|
55
|
+
record(params: {
|
|
56
|
+
before: bigint;
|
|
57
|
+
limit: number;
|
|
58
|
+
page: MessageStructure[];
|
|
59
|
+
/** Epoch ms the request was issued, for deciding whether this segment is live. */
|
|
60
|
+
at?: number;
|
|
61
|
+
}): void;
|
|
62
|
+
/**
|
|
63
|
+
* Extend the live segment with a pushed message. Ignored unless a segment is
|
|
64
|
+
* currently live: outside that, the message and the segment may have a gap
|
|
65
|
+
* between them, and claiming coverage across it would hide messages forever.
|
|
66
|
+
*/
|
|
67
|
+
recordLive(message: MessageStructure): void;
|
|
68
|
+
/**
|
|
69
|
+
* Stop trusting the live feed to extend coverage — call when the socket drops.
|
|
70
|
+
* Messages sent while disconnected would leave a hole, so segments go back to
|
|
71
|
+
* proving coverage by fetching.
|
|
72
|
+
*/
|
|
73
|
+
sealTips(): void;
|
|
74
|
+
/** Test/debug view. */
|
|
75
|
+
snapshot(): RangeSegment[];
|
|
76
|
+
clear(): void;
|
|
77
|
+
/** Union `incoming` with every segment it overlaps or merely touches. */
|
|
78
|
+
private merge;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stores are per *stream* — agent, channel, and any `fields` filter, which
|
|
82
|
+
* returns a different set of messages. Deliberately NOT per anchor: sharing
|
|
83
|
+
* across anchors is the entire point.
|
|
84
|
+
*/
|
|
85
|
+
export declare function getChannelRangeStore(key: string): ChannelRangeStore;
|
|
86
|
+
/** Drop every store. Tests only — there is no eviction in normal operation. */
|
|
87
|
+
export declare function resetChannelRangeStores(): void;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChannelRangeStore = void 0;
|
|
4
|
+
exports.getChannelRangeStore = getChannelRangeStore;
|
|
5
|
+
exports.resetChannelRangeStores = resetChannelRangeStores;
|
|
6
|
+
/**
|
|
7
|
+
* How close to "now" a fetch's `before` must be for its segment to accept live
|
|
8
|
+
* pushes. Beyond this the feed and the segment may not be contiguous.
|
|
9
|
+
*/
|
|
10
|
+
const LIVE_PROOF_TOLERANCE_MS = 60000;
|
|
11
|
+
const idOf = (message) => BigInt(message.id);
|
|
12
|
+
function sortById(messages) {
|
|
13
|
+
return [...messages].sort((a, b) => (idOf(a) < idOf(b) ? -1 : idOf(a) > idOf(b) ? 1 : 0));
|
|
14
|
+
}
|
|
15
|
+
function dedupeById(messages) {
|
|
16
|
+
const seen = new Map();
|
|
17
|
+
// Later wins: a live push or a refetch supersedes an older copy
|
|
18
|
+
for (const message of messages)
|
|
19
|
+
seen.set(message.id, message);
|
|
20
|
+
return sortById([...seen.values()]);
|
|
21
|
+
}
|
|
22
|
+
class ChannelRangeStore {
|
|
23
|
+
constructor() {
|
|
24
|
+
/** Disjoint, ascending by `lo`. */
|
|
25
|
+
this.segments = [];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Answer "the newest `limit` messages strictly older than `before`" from cache,
|
|
29
|
+
* or `undefined` when the range isn't proven and the caller must fetch.
|
|
30
|
+
*
|
|
31
|
+
* Returns ascending by id, matching `listMessages({ order: "asc" })`.
|
|
32
|
+
*/
|
|
33
|
+
read(before, limit) {
|
|
34
|
+
const segment = this.segments.find((s) => before <= s.hi && (s.atStart || before >= s.lo));
|
|
35
|
+
if (!segment)
|
|
36
|
+
return undefined;
|
|
37
|
+
const older = segment.messages.filter((m) => idOf(m) < before);
|
|
38
|
+
if (older.length >= limit)
|
|
39
|
+
return older.slice(older.length - limit);
|
|
40
|
+
// Short of `limit` only tells us the range is exhausted if nothing precedes it
|
|
41
|
+
return segment.atStart ? older : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Record a fetched page. `before` is the cursor actually sent and `limit` the
|
|
45
|
+
* one actually requested — a short page proves the range bottoms out, which is
|
|
46
|
+
* only sound because the server rejects an over-cap `limit` rather than
|
|
47
|
+
* silently clamping it.
|
|
48
|
+
*/
|
|
49
|
+
record(params) {
|
|
50
|
+
const { before, limit, page } = params;
|
|
51
|
+
const at = params.at ?? Date.now();
|
|
52
|
+
const messages = sortById(page);
|
|
53
|
+
const atStart = messages.length < limit;
|
|
54
|
+
const lo = messages.length > 0 ? idOf(messages[0]) : before;
|
|
55
|
+
const live = timestampOf(before) >= at - LIVE_PROOF_TOLERANCE_MS;
|
|
56
|
+
this.merge({ lo, hi: before, atStart, live, messages });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Extend the live segment with a pushed message. Ignored unless a segment is
|
|
60
|
+
* currently live: outside that, the message and the segment may have a gap
|
|
61
|
+
* between them, and claiming coverage across it would hide messages forever.
|
|
62
|
+
*/
|
|
63
|
+
recordLive(message) {
|
|
64
|
+
const id = idOf(message);
|
|
65
|
+
const tip = this.segments.find((s) => s.live);
|
|
66
|
+
if (!tip || id < tip.lo)
|
|
67
|
+
return;
|
|
68
|
+
tip.messages = dedupeById([...tip.messages, message]);
|
|
69
|
+
if (id >= tip.hi)
|
|
70
|
+
tip.hi = id + 1n;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Stop trusting the live feed to extend coverage — call when the socket drops.
|
|
74
|
+
* Messages sent while disconnected would leave a hole, so segments go back to
|
|
75
|
+
* proving coverage by fetching.
|
|
76
|
+
*/
|
|
77
|
+
sealTips() {
|
|
78
|
+
for (const segment of this.segments)
|
|
79
|
+
segment.live = false;
|
|
80
|
+
}
|
|
81
|
+
/** Test/debug view. */
|
|
82
|
+
snapshot() {
|
|
83
|
+
return this.segments.map((s) => ({ ...s, messages: [...s.messages] }));
|
|
84
|
+
}
|
|
85
|
+
clear() {
|
|
86
|
+
this.segments = [];
|
|
87
|
+
}
|
|
88
|
+
/** Union `incoming` with every segment it overlaps or merely touches. */
|
|
89
|
+
merge(incoming) {
|
|
90
|
+
const overlapping = [];
|
|
91
|
+
const rest = [];
|
|
92
|
+
for (const segment of this.segments) {
|
|
93
|
+
// Touching counts: [a,b) and [b,c) are both proven, so [a,c) is too
|
|
94
|
+
const disjoint = segment.hi < incoming.lo || segment.lo > incoming.hi;
|
|
95
|
+
(disjoint ? rest : overlapping).push(segment);
|
|
96
|
+
}
|
|
97
|
+
const merged = overlapping.reduce((acc, s) => ({
|
|
98
|
+
lo: s.lo < acc.lo ? s.lo : acc.lo,
|
|
99
|
+
hi: s.hi > acc.hi ? s.hi : acc.hi,
|
|
100
|
+
atStart: acc.atStart || s.atStart,
|
|
101
|
+
live: acc.live || s.live,
|
|
102
|
+
messages: [...acc.messages, ...s.messages],
|
|
103
|
+
}), incoming);
|
|
104
|
+
merged.messages = dedupeById(merged.messages);
|
|
105
|
+
if (merged.atStart && merged.messages.length > 0) {
|
|
106
|
+
merged.lo = idOf(merged.messages[0]);
|
|
107
|
+
}
|
|
108
|
+
this.segments = [...rest, merged].sort((a, b) => (a.lo < b.lo ? -1 : a.lo > b.lo ? 1 : 0));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
exports.ChannelRangeStore = ChannelRangeStore;
|
|
112
|
+
const SNOWFLAKE_EPOCH_MS = 1735689600000n;
|
|
113
|
+
/** Epoch ms encoded in a snowflake, without going through the string round-trip. */
|
|
114
|
+
function timestampOf(id) {
|
|
115
|
+
return Number((id >> 22n) + SNOWFLAKE_EPOCH_MS);
|
|
116
|
+
}
|
|
117
|
+
const stores = new Map();
|
|
118
|
+
/**
|
|
119
|
+
* Stores are per *stream* — agent, channel, and any `fields` filter, which
|
|
120
|
+
* returns a different set of messages. Deliberately NOT per anchor: sharing
|
|
121
|
+
* across anchors is the entire point.
|
|
122
|
+
*/
|
|
123
|
+
function getChannelRangeStore(key) {
|
|
124
|
+
let store = stores.get(key);
|
|
125
|
+
if (!store) {
|
|
126
|
+
store = new ChannelRangeStore();
|
|
127
|
+
stores.set(key, store);
|
|
128
|
+
}
|
|
129
|
+
return store;
|
|
130
|
+
}
|
|
131
|
+
/** Drop every store. Tests only — there is no eviction in normal operation. */
|
|
132
|
+
function resetChannelRangeStores() {
|
|
133
|
+
stores.clear();
|
|
134
|
+
}
|
|
@@ -2,7 +2,13 @@ import { type InfiniteData, type UseInfiniteQueryResult } from "@tanstack/react-
|
|
|
2
2
|
import type { MessageStructure } from "../types/common";
|
|
3
3
|
import type { ChannelIdentifier } from "../types/viewer";
|
|
4
4
|
import { type OfflineStatusSnapshot } from "./useOfflineStatus";
|
|
5
|
-
export declare function channelMessagesQueryKey(agentId: string | undefined, channelName: string | undefined, fields?: readonly string[], sources?: string[]): readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
|
|
5
|
+
export declare function channelMessagesQueryKey(agentId: string | undefined, channelName: string | undefined, fields?: readonly string[], sources?: string[], anchor?: string): readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
|
|
6
|
+
readonly anchor: string;
|
|
7
|
+
}] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
|
|
8
|
+
readonly fields: string[];
|
|
9
|
+
}] | readonly ["doover", "agent", string | undefined, "channel", string | undefined, "messages", "src", string, {
|
|
10
|
+
readonly anchor: string;
|
|
11
|
+
}, {
|
|
6
12
|
readonly fields: string[];
|
|
7
13
|
}];
|
|
8
14
|
export interface UseChannelMessagesOptions {
|
|
@@ -22,6 +28,20 @@ export interface UseChannelMessagesOptions {
|
|
|
22
28
|
* snowflake so server-stamped messages don't get missed).
|
|
23
29
|
*/
|
|
24
30
|
initialBefore?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Pin the window to a point in history (snowflake id), for "jump to this
|
|
33
|
+
* date" navigation. Changing it re-anchors and refetches.
|
|
34
|
+
*
|
|
35
|
+
* Distinct from `initialBefore`, which is a clock-skew seed regenerated per
|
|
36
|
+
* mount and therefore must NOT dimension the cache key. An anchor identifies
|
|
37
|
+
* a stream the caller means to look at, so it does. Pass a stable value — a
|
|
38
|
+
* per-render `now` would refetch on every render.
|
|
39
|
+
*
|
|
40
|
+
* Cheaper than it looks: anchored windows share the range cache with every
|
|
41
|
+
* other window on the channel, so re-anchoring over already-fetched history
|
|
42
|
+
* costs no requests.
|
|
43
|
+
*/
|
|
44
|
+
anchor?: string;
|
|
25
45
|
/**
|
|
26
46
|
* Optional lower-bound snowflake id. The server only returns messages
|
|
27
47
|
* newer than this on every page, so paginating older eventually returns
|
|
@@ -4,14 +4,21 @@ exports.channelMessagesQueryKey = channelMessagesQueryKey;
|
|
|
4
4
|
exports.useChannelMessages = useChannelMessages;
|
|
5
5
|
const react_1 = require("react");
|
|
6
6
|
const react_query_1 = require("@tanstack/react-query");
|
|
7
|
+
const snowflake_1 = require("../utils/snowflake");
|
|
8
|
+
const messageRangeStore_1 = require("./messageRangeStore");
|
|
7
9
|
const context_1 = require("./context");
|
|
8
10
|
const useChannelSubscription_1 = require("./useChannelSubscription");
|
|
9
11
|
const useOfflineStatus_1 = require("./useOfflineStatus");
|
|
10
|
-
function channelMessagesQueryKey(agentId, channelName, fields, sources) {
|
|
12
|
+
function channelMessagesQueryKey(agentId, channelName, fields, sources, anchor) {
|
|
11
13
|
// Source-dimension the key (multiplex source subsets cache independently);
|
|
12
14
|
// "*" means "all/unspecified" for a plain DooverClient/LocalAgentClient.
|
|
13
15
|
const sourceDim = sources && sources.length ? [...sources].sort().join(",") : "*";
|
|
14
|
-
const
|
|
16
|
+
const base = ["doover", "agent", agentId, "channel", channelName, "messages", "src", sourceDim];
|
|
17
|
+
// An anchored window is its own chain of cursors, so it must not share a cache
|
|
18
|
+
// entry with the live one — same reasoning as `fields` below. Note this is
|
|
19
|
+
// deliberately absent from the range store's key: sharing ranges across
|
|
20
|
+
// anchors is the point of it.
|
|
21
|
+
const key = anchor ? [...base, { anchor }] : base;
|
|
15
22
|
if (!fields || fields.length === 0)
|
|
16
23
|
return key;
|
|
17
24
|
// A `field_name`-filtered request returns a different message stream than
|
|
@@ -22,6 +29,8 @@ function channelMessagesQueryKey(agentId, channelName, fields, sources) {
|
|
|
22
29
|
// order doesn't fragment the cache.
|
|
23
30
|
return [...key, { fields: [...fields].sort() }];
|
|
24
31
|
}
|
|
32
|
+
/** Mirrors `MessagesApi.listMessages`'s own default. */
|
|
33
|
+
const DEFAULT_PAGE_LIMIT = 10;
|
|
25
34
|
/**
|
|
26
35
|
* Paginated infinite query over `DooverDataProvider.getMessages`, with live
|
|
27
36
|
* `messageCreate` pushes prepended/appended to the newest page. The "next"
|
|
@@ -36,10 +45,27 @@ function useChannelMessages(identifier, options) {
|
|
|
36
45
|
const liveUpdates = options?.liveUpdates ?? true;
|
|
37
46
|
const fields = options?.fields;
|
|
38
47
|
const initialBefore = options?.initialBefore;
|
|
48
|
+
const anchor = options?.anchor;
|
|
39
49
|
const after = options?.after;
|
|
40
50
|
const autoPaginate = options?.autoPaginate ?? false;
|
|
41
51
|
const sources = options?.sources;
|
|
42
|
-
const key = channelMessagesQueryKey(agentId, channelName, fields, sources);
|
|
52
|
+
const key = channelMessagesQueryKey(agentId, channelName, fields, sources, anchor);
|
|
53
|
+
// Keyed per stream, NOT per anchor — every window on this channel shares it.
|
|
54
|
+
const storeKey = JSON.stringify(channelMessagesQueryKey(agentId, channelName, fields, sources));
|
|
55
|
+
const store = (0, messageRangeStore_1.getChannelRangeStore)(storeKey);
|
|
56
|
+
// `after`/`sources` change what a page means (a short page proves a bound was
|
|
57
|
+
// hit, not that history ran out; multiplex merges several streams), so those
|
|
58
|
+
// requests bypass the range cache rather than record unsound coverage.
|
|
59
|
+
const rangeCacheable = after === undefined && !sources;
|
|
60
|
+
// A dropped socket may have swallowed messages, so the live feed stops being
|
|
61
|
+
// proof of coverage until a fetch re-establishes it.
|
|
62
|
+
(0, react_1.useEffect)(() => {
|
|
63
|
+
const onClose = () => store.sealTips();
|
|
64
|
+
client.gateway.on("close", onClose);
|
|
65
|
+
return () => {
|
|
66
|
+
client.gateway.off("close", onClose);
|
|
67
|
+
};
|
|
68
|
+
}, [client, store]);
|
|
43
69
|
const onMessage = (0, react_1.useCallback)((message) => {
|
|
44
70
|
// This cache entry is scoped to `fields`; the gateway delivers every
|
|
45
71
|
// message on the channel, so drop live pushes whose payload doesn't
|
|
@@ -52,6 +78,8 @@ function useChannelMessages(identifier, options) {
|
|
|
52
78
|
return;
|
|
53
79
|
}
|
|
54
80
|
}
|
|
81
|
+
if (rangeCacheable)
|
|
82
|
+
store.recordLive(message);
|
|
55
83
|
queryClient.setQueryData(key, (current) => {
|
|
56
84
|
if (!current)
|
|
57
85
|
return current;
|
|
@@ -71,14 +99,27 @@ function useChannelMessages(identifier, options) {
|
|
|
71
99
|
queryKey: key,
|
|
72
100
|
enabled: !!agentId && !!channelName,
|
|
73
101
|
staleTime: Infinity,
|
|
74
|
-
initialPageParam: initialBefore,
|
|
102
|
+
initialPageParam: (anchor ?? initialBefore),
|
|
75
103
|
getNextPageParam: (lastPage) => lastPage && lastPage.length > 0 ? lastPage[0]?.id : undefined,
|
|
76
104
|
queryFn: async ({ pageParam }) => {
|
|
77
105
|
if (!agentId || !channelName)
|
|
78
106
|
return [];
|
|
79
107
|
const id = { agentId, channelName };
|
|
108
|
+
// Resolve `before` here rather than letting the API default it, so the
|
|
109
|
+
// range store records the exact bound the request proved.
|
|
110
|
+
const before = typeof pageParam === "string"
|
|
111
|
+
? pageParam
|
|
112
|
+
: (0, snowflake_1.generateSnowflakeIdAtTime)(new Date());
|
|
113
|
+
// Mirrors the API's own default; the store needs the limit actually applied
|
|
114
|
+
const effectiveLimit = limit ?? DEFAULT_PAGE_LIMIT;
|
|
115
|
+
if (rangeCacheable) {
|
|
116
|
+
const cached = store.read(BigInt(before), effectiveLimit);
|
|
117
|
+
if (cached)
|
|
118
|
+
return cached;
|
|
119
|
+
}
|
|
120
|
+
const requestedAt = Date.now();
|
|
80
121
|
const params = {
|
|
81
|
-
|
|
122
|
+
before,
|
|
82
123
|
...(limit !== undefined ? { limit } : {}),
|
|
83
124
|
...(fields && fields.length > 0 ? { field_name: fields } : {}),
|
|
84
125
|
...(after !== undefined ? { after } : {}),
|
|
@@ -90,6 +131,14 @@ function useChannelMessages(identifier, options) {
|
|
|
90
131
|
const page = sourcesArg
|
|
91
132
|
? await client.messages.listMessages(id, params, sourcesArg)
|
|
92
133
|
: await client.messages.listMessages(id, params);
|
|
134
|
+
if (rangeCacheable) {
|
|
135
|
+
store.record({
|
|
136
|
+
before: BigInt(before),
|
|
137
|
+
limit: effectiveLimit,
|
|
138
|
+
page: page,
|
|
139
|
+
at: requestedAt,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
93
142
|
return page;
|
|
94
143
|
},
|
|
95
144
|
});
|
|
@@ -27,6 +27,21 @@ export interface UseMultiAgentChannelMessagesOptions {
|
|
|
27
27
|
fields?: string[];
|
|
28
28
|
/** Optional first-page `before` cursor (snowflake id). */
|
|
29
29
|
initialBefore?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Keep paging older automatically until `hasNextPage` is false. Each page
|
|
32
|
+
* resumes only the agents that hit `agentMessageLimit`, using their own
|
|
33
|
+
* cursors, so a noisy agent doesn't drag the quiet ones back through
|
|
34
|
+
* messages they've already returned. Almost always combined with `after`
|
|
35
|
+
* — without a lower bound this walks every agent's channel back to its
|
|
36
|
+
* first message. `maxPages` caps the walk.
|
|
37
|
+
*/
|
|
38
|
+
autoPaginate?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Hard cap on pages fetched when `autoPaginate` is set. Defaults to 20.
|
|
41
|
+
* Guards against a window that's wider than `agentMessageLimit × maxPages`
|
|
42
|
+
* messages turning into an unbounded fetch loop.
|
|
43
|
+
*/
|
|
44
|
+
maxPages?: number;
|
|
30
45
|
/**
|
|
31
46
|
* Optional lower-bound snowflake id. Forwarded server-side so each
|
|
32
47
|
* agent's pagination stops on its own once it walks past the bound —
|
|
@@ -45,7 +60,16 @@ export interface UseMultiAgentChannelMessagesOptions {
|
|
|
45
60
|
}
|
|
46
61
|
interface Page<TData> {
|
|
47
62
|
results: MessageStructure<TData>[];
|
|
48
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Per-agent resume cursors, keyed by agent id. Present (and non-empty)
|
|
65
|
+
* only for agents that hit the per-agent limit before draining the
|
|
66
|
+
* window. This is the canonical "there is more" signal.
|
|
67
|
+
*/
|
|
68
|
+
next_cursors?: Record<string, string>;
|
|
69
|
+
/** Legacy single-cursor signal, for servers predating `next_cursors`. */
|
|
70
|
+
next?: string | null;
|
|
71
|
+
/** Legacy: agent ids that may have more older messages. */
|
|
72
|
+
at_limit_agent_ids?: string[];
|
|
49
73
|
}
|
|
50
74
|
export interface UseMultiAgentChannelMessagesResult<TData> extends Omit<UseInfiniteQueryResult<InfiniteData<Page<TData>>>, "data"> {
|
|
51
75
|
messages: MessageStructure<TData>[];
|
|
@@ -30,6 +30,33 @@ function multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, scope
|
|
|
30
30
|
? [...base, scopeKey]
|
|
31
31
|
: base;
|
|
32
32
|
}
|
|
33
|
+
function nextPageParam(lastPage) {
|
|
34
|
+
if (!lastPage)
|
|
35
|
+
return undefined;
|
|
36
|
+
const cursors = lastPage.next_cursors;
|
|
37
|
+
if (cursors) {
|
|
38
|
+
const entries = Object.entries(cursors);
|
|
39
|
+
// Present-but-empty means every agent drained its window: we're done.
|
|
40
|
+
if (entries.length === 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
return {
|
|
43
|
+
agentIds: entries.map(([id]) => id),
|
|
44
|
+
agentBefore: entries.map(([, cursor]) => cursor),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// Legacy server: one global `before` for whichever agents were at limit.
|
|
48
|
+
// Those agents whose own cursor is older than `next` re-return a few
|
|
49
|
+
// messages we already hold — harmless, since we dedupe by id below.
|
|
50
|
+
if (typeof lastPage.next === "string" && lastPage.next) {
|
|
51
|
+
return {
|
|
52
|
+
before: lastPage.next,
|
|
53
|
+
...(lastPage.at_limit_agent_ids?.length
|
|
54
|
+
? { agentIds: lastPage.at_limit_agent_ids }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
33
60
|
function useMultiAgentChannelMessages(channelName, agentIds, options) {
|
|
34
61
|
const client = (0, context_1.useDooverClient)();
|
|
35
62
|
const queryClient = (0, react_query_1.useQueryClient)();
|
|
@@ -40,6 +67,8 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
|
|
|
40
67
|
const initialBefore = options?.initialBefore;
|
|
41
68
|
const sources = options?.sources;
|
|
42
69
|
const after = options?.after;
|
|
70
|
+
const autoPaginate = options?.autoPaginate ?? false;
|
|
71
|
+
const maxPages = options?.maxPages ?? 20;
|
|
43
72
|
const key = multiAgentChannelMessagesQueryKey(channelName, agentIds, sources, {
|
|
44
73
|
after,
|
|
45
74
|
fields,
|
|
@@ -86,12 +115,13 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
|
|
|
86
115
|
queryKey: key,
|
|
87
116
|
enabled: agentIds.length > 0,
|
|
88
117
|
staleTime: Infinity,
|
|
89
|
-
initialPageParam: initialBefore,
|
|
90
|
-
getNextPageParam:
|
|
118
|
+
initialPageParam: (initialBefore ? { before: initialBefore } : {}),
|
|
119
|
+
getNextPageParam: nextPageParam,
|
|
91
120
|
queryFn: async ({ pageParam }) => {
|
|
92
121
|
const params = {
|
|
93
|
-
agent_id: agentIds,
|
|
94
|
-
...(
|
|
122
|
+
agent_id: pageParam.agentIds ?? agentIds,
|
|
123
|
+
...(pageParam.before ? { before: pageParam.before } : {}),
|
|
124
|
+
...(pageParam.agentBefore ? { agent_before: pageParam.agentBefore } : {}),
|
|
95
125
|
...(limit !== undefined ? { limit } : {}),
|
|
96
126
|
...(agentMessageLimit !== undefined
|
|
97
127
|
? { agent_message_limit: agentMessageLimit }
|
|
@@ -108,6 +138,36 @@ function useMultiAgentChannelMessages(channelName, agentIds, options) {
|
|
|
108
138
|
return page;
|
|
109
139
|
},
|
|
110
140
|
});
|
|
111
|
-
|
|
141
|
+
(0, react_1.useEffect)(() => {
|
|
142
|
+
if (!autoPaginate)
|
|
143
|
+
return;
|
|
144
|
+
if (query.isFetching || !query.hasNextPage)
|
|
145
|
+
return;
|
|
146
|
+
if ((query.data?.pages.length ?? 0) >= maxPages)
|
|
147
|
+
return;
|
|
148
|
+
query.fetchNextPage();
|
|
149
|
+
}, [
|
|
150
|
+
autoPaginate,
|
|
151
|
+
maxPages,
|
|
152
|
+
query.isFetching,
|
|
153
|
+
query.hasNextPage,
|
|
154
|
+
query.fetchNextPage,
|
|
155
|
+
query.data?.pages.length,
|
|
156
|
+
]);
|
|
157
|
+
const messages = (0, react_1.useMemo)(() => {
|
|
158
|
+
// The legacy single-cursor path (and a live push that races a page
|
|
159
|
+
// fetch) can hand us the same message twice; keep the first copy.
|
|
160
|
+
const seen = new Set();
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const page of query.data?.pages ?? []) {
|
|
163
|
+
for (const message of page.results) {
|
|
164
|
+
if (seen.has(message.id))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(message.id);
|
|
167
|
+
out.push(message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}, [query.data]);
|
|
112
172
|
return { ...query, messages };
|
|
113
173
|
}
|
|
@@ -6,9 +6,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const chai_1 = require("chai");
|
|
7
7
|
const mocha_1 = require("mocha");
|
|
8
8
|
const sinon_1 = __importDefault(require("sinon"));
|
|
9
|
+
const doover_auth_1 = require("../auth/doover-auth");
|
|
10
|
+
const errors_1 = require("../auth/errors");
|
|
9
11
|
const gateway_client_1 = require("../gateway/gateway-client");
|
|
10
12
|
const snowflake_1 = require("../utils/snowflake");
|
|
11
13
|
const helpers_1 = require("./helpers");
|
|
14
|
+
class RejectingAuth extends doover_auth_1.DooverAuth {
|
|
15
|
+
async getHttpHeaders() {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
getFetchCredentials() {
|
|
19
|
+
return "omit";
|
|
20
|
+
}
|
|
21
|
+
async prepareWebSocket(url) {
|
|
22
|
+
return { url };
|
|
23
|
+
}
|
|
24
|
+
setToken() {
|
|
25
|
+
// no-op for test
|
|
26
|
+
}
|
|
27
|
+
setRefreshToken() {
|
|
28
|
+
// no-op for test
|
|
29
|
+
}
|
|
30
|
+
async ensureReady() {
|
|
31
|
+
throw new errors_1.DooverAuthError("Authentication is invalid; sign in again");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
12
34
|
(0, mocha_1.describe)("GatewayClient", () => {
|
|
13
35
|
(0, mocha_1.beforeEach)(() => {
|
|
14
36
|
helpers_1.MockWebSocket.reset();
|
|
@@ -229,6 +251,27 @@ const helpers_1 = require("./helpers");
|
|
|
229
251
|
(0, chai_1.expect)(cancelled.calledOnce).to.equal(true);
|
|
230
252
|
(0, chai_1.expect)(client.getSession()).to.equal(null);
|
|
231
253
|
});
|
|
254
|
+
(0, mocha_1.it)("reports background connect auth failures without creating a socket", async () => {
|
|
255
|
+
const client = new gateway_client_1.GatewayClient({
|
|
256
|
+
dataRestUrl: "https://api.example.com",
|
|
257
|
+
controlApiUrl: "https://control.example.com",
|
|
258
|
+
dataWssUrl: "wss://ws.example.com",
|
|
259
|
+
webSocketImpl: helpers_1.MockWebSocket,
|
|
260
|
+
}, new RejectingAuth());
|
|
261
|
+
const wssError = sinon_1.default.spy();
|
|
262
|
+
client.on("wssError", wssError);
|
|
263
|
+
client.subscribe({ agent_id: "a1", name: "c1" });
|
|
264
|
+
await Promise.resolve();
|
|
265
|
+
await Promise.resolve();
|
|
266
|
+
await Promise.resolve();
|
|
267
|
+
await Promise.resolve();
|
|
268
|
+
await Promise.resolve();
|
|
269
|
+
(0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
|
|
270
|
+
(0, chai_1.expect)(wssError.calledOnce).to.equal(true);
|
|
271
|
+
(0, chai_1.expect)(wssError.firstCall.args[0]).to.deep.equal({
|
|
272
|
+
message: "Authentication is invalid; sign in again",
|
|
273
|
+
});
|
|
274
|
+
});
|
|
232
275
|
(0, mocha_1.it)("reconnects with exponential backoff + jitter on unexpected close", () => {
|
|
233
276
|
// Make jitter deterministic: Math.random() → 1 means "wait the full backoff window".
|
|
234
277
|
const randomStub = sinon_1.default.stub(Math, "random").returns(1);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const chai_1 = require("chai");
|
|
4
|
+
const mocha_1 = require("mocha");
|
|
5
|
+
const messageRangeStore_1 = require("../react/messageRangeStore");
|
|
6
|
+
const snowflake_1 = require("../utils/snowflake");
|
|
7
|
+
/** Frozen so a cursor built twice is the same cursor — real clocks make these flaky. */
|
|
8
|
+
const NOW = Date.now();
|
|
9
|
+
/** Messages at 1-minute spacing, oldest first, ending `minutesAgo` before NOW. */
|
|
10
|
+
function makeMessages(count, startMinutesAgo) {
|
|
11
|
+
return Array.from({ length: count }, (_, i) => {
|
|
12
|
+
const at = new Date(NOW - (startMinutesAgo - i) * 60000);
|
|
13
|
+
return {
|
|
14
|
+
id: (0, snowflake_1.generateSnowflakeIdAtTime)(at),
|
|
15
|
+
data: { i },
|
|
16
|
+
attachments: [],
|
|
17
|
+
author_id: "a",
|
|
18
|
+
channel: { agentId: "agent", channelName: "chan" },
|
|
19
|
+
timestamp: at.getTime(),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const idAt = (minutesAgo) => BigInt((0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - minutesAgo * 60000)));
|
|
24
|
+
const ids = (messages) => (messages ?? []).map((m) => m.id);
|
|
25
|
+
(0, mocha_1.describe)("ChannelRangeStore", () => {
|
|
26
|
+
let store;
|
|
27
|
+
(0, mocha_1.beforeEach)(() => {
|
|
28
|
+
store = new messageRangeStore_1.ChannelRangeStore();
|
|
29
|
+
});
|
|
30
|
+
(0, mocha_1.it)("returns undefined for a range it has never seen", () => {
|
|
31
|
+
(0, chai_1.expect)(store.read(idAt(0), 10)).to.equal(undefined);
|
|
32
|
+
});
|
|
33
|
+
(0, mocha_1.it)("serves a full page back from cache", () => {
|
|
34
|
+
const page = makeMessages(10, 20);
|
|
35
|
+
store.record({ before: idAt(0), limit: 10, page });
|
|
36
|
+
const read = store.read(idAt(0), 10);
|
|
37
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
|
|
38
|
+
});
|
|
39
|
+
(0, mocha_1.it)("returns messages ascending, matching order:asc", () => {
|
|
40
|
+
const page = makeMessages(5, 20);
|
|
41
|
+
store.record({ before: idAt(0), limit: 5, page });
|
|
42
|
+
const read = store.read(idAt(0), 5);
|
|
43
|
+
for (let i = 1; i < read.length; i++) {
|
|
44
|
+
(0, chai_1.expect)(BigInt(read[i].id) > BigInt(read[i - 1].id)).to.equal(true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
(0, mocha_1.it)("serves the NEWEST `limit` older than the cursor, not the first", () => {
|
|
48
|
+
const page = makeMessages(10, 20); // 20..11 minutes ago
|
|
49
|
+
store.record({ before: idAt(0), limit: 10, page });
|
|
50
|
+
const read = store.read(idAt(0), 3);
|
|
51
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal(ids(page.slice(7)));
|
|
52
|
+
});
|
|
53
|
+
(0, mocha_1.it)("excludes the cursor message itself (`before` is exclusive)", () => {
|
|
54
|
+
const page = makeMessages(5, 20);
|
|
55
|
+
store.record({ before: idAt(0), limit: 5, page });
|
|
56
|
+
// Only 2 messages sit below page[2], so asking for more would (rightly) be
|
|
57
|
+
// unanswerable — nothing proves what lies below the segment's `lo`.
|
|
58
|
+
const cursor = BigInt(page[2].id);
|
|
59
|
+
const read = store.read(cursor, 2);
|
|
60
|
+
(0, chai_1.expect)(read.every((m) => BigInt(m.id) < cursor)).to.equal(true);
|
|
61
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal(ids(page.slice(0, 2)));
|
|
62
|
+
});
|
|
63
|
+
(0, mocha_1.it)("will not answer beyond what it has proven", () => {
|
|
64
|
+
const page = makeMessages(10, 20);
|
|
65
|
+
store.record({ before: idAt(0), limit: 10, page });
|
|
66
|
+
// Holds 10, asked for 20, and nothing says the range bottoms out here
|
|
67
|
+
(0, chai_1.expect)(store.read(idAt(0), 20)).to.equal(undefined);
|
|
68
|
+
});
|
|
69
|
+
(0, mocha_1.it)("answers short when a short page proved the range bottoms out", () => {
|
|
70
|
+
const page = makeMessages(3, 20);
|
|
71
|
+
store.record({ before: idAt(0), limit: 10, page }); // 3 < 10 => atStart
|
|
72
|
+
const read = store.read(idAt(0), 10);
|
|
73
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal(ids(page));
|
|
74
|
+
});
|
|
75
|
+
(0, mocha_1.it)("will not answer a cursor above its proven upper bound", () => {
|
|
76
|
+
const page = makeMessages(10, 20);
|
|
77
|
+
store.record({ before: idAt(10), limit: 10, page });
|
|
78
|
+
// Nothing proves what sits between 10 minutes ago and now
|
|
79
|
+
(0, chai_1.expect)(store.read(idAt(0), 5)).to.equal(undefined);
|
|
80
|
+
});
|
|
81
|
+
(0, mocha_1.it)("merges touching ranges so a later read spans both", () => {
|
|
82
|
+
const older = makeMessages(10, 40); // 40..31
|
|
83
|
+
const newer = makeMessages(10, 20); // 20..11
|
|
84
|
+
store.record({ before: idAt(0), limit: 10, page: newer });
|
|
85
|
+
// Paging older: cursor is the oldest we held
|
|
86
|
+
store.record({ before: BigInt(newer[0].id), limit: 10, page: older, at: NOW });
|
|
87
|
+
(0, chai_1.expect)(store.snapshot()).to.have.length(1);
|
|
88
|
+
const read = store.read(idAt(0), 20);
|
|
89
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal([...ids(older), ...ids(newer)]);
|
|
90
|
+
});
|
|
91
|
+
(0, mocha_1.it)("merges overlapping ranges without duplicating", () => {
|
|
92
|
+
const page = makeMessages(10, 20);
|
|
93
|
+
store.record({ before: idAt(0), limit: 10, page });
|
|
94
|
+
store.record({ before: idAt(0), limit: 10, page }); // same window again
|
|
95
|
+
(0, chai_1.expect)(store.snapshot()).to.have.length(1);
|
|
96
|
+
(0, chai_1.expect)(ids(store.read(idAt(0), 10))).to.deep.equal(ids(page));
|
|
97
|
+
});
|
|
98
|
+
(0, mocha_1.it)("keeps disjoint ranges apart rather than claiming the gap", () => {
|
|
99
|
+
const ancient = makeMessages(5, 500);
|
|
100
|
+
const recent = makeMessages(5, 20);
|
|
101
|
+
store.record({ before: idAt(0), limit: 5, page: recent });
|
|
102
|
+
store.record({ before: idAt(400), limit: 5, page: ancient });
|
|
103
|
+
(0, chai_1.expect)(store.snapshot()).to.have.length(2);
|
|
104
|
+
// The hole between them is not covered, so a span across it must refetch
|
|
105
|
+
(0, chai_1.expect)(store.read(idAt(0), 10)).to.equal(undefined);
|
|
106
|
+
});
|
|
107
|
+
(0, mocha_1.it)("serves an anchored read from a range fetched for a different anchor", () => {
|
|
108
|
+
const page = makeMessages(30, 60);
|
|
109
|
+
store.record({ before: idAt(0), limit: 30, page });
|
|
110
|
+
// Jumping to 30 minutes ago costs nothing: already proven
|
|
111
|
+
const read = store.read(idAt(30), 5);
|
|
112
|
+
(0, chai_1.expect)(read).to.not.equal(undefined);
|
|
113
|
+
(0, chai_1.expect)(read.every((m) => m.timestamp < Date.now() - 30 * 60000)).to.equal(true);
|
|
114
|
+
});
|
|
115
|
+
(0, mocha_1.it)("orders by numeric id, not string (500 sorts before 4000)", () => {
|
|
116
|
+
const short = { id: "500", timestamp: 1 };
|
|
117
|
+
const long = { id: "4000", timestamp: 2 };
|
|
118
|
+
store.record({ before: 5000n, limit: 10, page: [long, short], at: NOW });
|
|
119
|
+
(0, chai_1.expect)(ids(store.read(5000n, 10))).to.deep.equal(["500", "4000"]);
|
|
120
|
+
});
|
|
121
|
+
(0, mocha_1.it)("records an empty page as a proven-empty range", () => {
|
|
122
|
+
store.record({ before: idAt(0), limit: 10, page: [] });
|
|
123
|
+
(0, chai_1.expect)(store.read(idAt(0), 10)).to.deep.equal([]);
|
|
124
|
+
});
|
|
125
|
+
(0, mocha_1.describe)("live tip", () => {
|
|
126
|
+
(0, mocha_1.it)("extends coverage with pushes when proven up to now", () => {
|
|
127
|
+
const page = makeMessages(5, 20);
|
|
128
|
+
store.record({ before: idAt(0), limit: 5, page });
|
|
129
|
+
const [live] = makeMessages(1, -1); // one minute into the future
|
|
130
|
+
store.recordLive(live);
|
|
131
|
+
const read = store.read(BigInt(live.id) + 1n, 6);
|
|
132
|
+
(0, chai_1.expect)(ids(read)).to.deep.equal([...ids(page), live.id]);
|
|
133
|
+
});
|
|
134
|
+
(0, mocha_1.it)("ignores pushes for a stale window, which may not be contiguous", () => {
|
|
135
|
+
const page = makeMessages(5, 500);
|
|
136
|
+
// Proven only up to ~8 hours ago, so a push now leaves a hole
|
|
137
|
+
store.record({ before: idAt(480), limit: 5, page });
|
|
138
|
+
const [live] = makeMessages(1, 0);
|
|
139
|
+
store.recordLive(live);
|
|
140
|
+
(0, chai_1.expect)(store.read(BigInt(live.id) + 1n, 6)).to.equal(undefined);
|
|
141
|
+
});
|
|
142
|
+
(0, mocha_1.it)("stops extending once the socket drops", () => {
|
|
143
|
+
const page = makeMessages(5, 20);
|
|
144
|
+
store.record({ before: idAt(0), limit: 5, page });
|
|
145
|
+
store.sealTips(); // gateway "close"
|
|
146
|
+
const [live] = makeMessages(1, -1);
|
|
147
|
+
store.recordLive(live);
|
|
148
|
+
// The push is not trusted to prove the range, so the gap must be refetched
|
|
149
|
+
(0, chai_1.expect)(store.read(BigInt(live.id) + 1n, 6)).to.equal(undefined);
|
|
150
|
+
});
|
|
151
|
+
(0, mocha_1.it)("does not duplicate a push already fetched", () => {
|
|
152
|
+
const page = makeMessages(5, 20);
|
|
153
|
+
store.record({ before: idAt(0), limit: 5, page });
|
|
154
|
+
store.recordLive(page[4]);
|
|
155
|
+
(0, chai_1.expect)(ids(store.read(idAt(0), 5))).to.deep.equal(ids(page));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
(0, mocha_1.describe)("getChannelRangeStore", () => {
|
|
160
|
+
(0, mocha_1.beforeEach)(() => (0, messageRangeStore_1.resetChannelRangeStores)());
|
|
161
|
+
(0, mocha_1.it)("shares one store per stream key", () => {
|
|
162
|
+
(0, chai_1.expect)((0, messageRangeStore_1.getChannelRangeStore)("a|c")).to.equal((0, messageRangeStore_1.getChannelRangeStore)("a|c"));
|
|
163
|
+
});
|
|
164
|
+
(0, mocha_1.it)("keeps different streams apart", () => {
|
|
165
|
+
(0, chai_1.expect)((0, messageRangeStore_1.getChannelRangeStore)("a|c")).to.not.equal((0, messageRangeStore_1.getChannelRangeStore)("a|d"));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
7
|
+
const chai_1 = require("chai");
|
|
8
|
+
const mocha_1 = require("mocha");
|
|
9
|
+
const react_1 = require("@testing-library/react");
|
|
10
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
11
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
12
|
+
const doover_client_1 = require("../client/doover-client");
|
|
13
|
+
const react_2 = require("../react");
|
|
14
|
+
const messageRangeStore_1 = require("../react/messageRangeStore");
|
|
15
|
+
const snowflake_1 = require("../utils/snowflake");
|
|
16
|
+
const helpers_1 = require("./helpers");
|
|
17
|
+
const AGENT = "agent-1";
|
|
18
|
+
const CHANNEL = "camera_1";
|
|
19
|
+
const NOW = Date.now();
|
|
20
|
+
/** Newest-first, as the bare REST route returns it. */
|
|
21
|
+
function messagesDesc(count, startMinutesAgo) {
|
|
22
|
+
return Array.from({ length: count }, (_, i) => {
|
|
23
|
+
const at = new Date(NOW - (startMinutesAgo + i) * 60000);
|
|
24
|
+
return {
|
|
25
|
+
id: (0, snowflake_1.generateSnowflakeIdAtTime)(at),
|
|
26
|
+
data: { i },
|
|
27
|
+
attachments: [],
|
|
28
|
+
author_id: "author",
|
|
29
|
+
channel: { agent_id: AGENT, name: CHANNEL },
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function makeClient(page) {
|
|
34
|
+
const calls = [];
|
|
35
|
+
const fetchMock = sinon_1.default.stub().callsFake(async (input) => {
|
|
36
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
37
|
+
if (url.includes("/messages")) {
|
|
38
|
+
const parsed = new URL(url);
|
|
39
|
+
calls.push({
|
|
40
|
+
before: parsed.searchParams.get("before"),
|
|
41
|
+
limit: parsed.searchParams.get("limit"),
|
|
42
|
+
});
|
|
43
|
+
return (0, helpers_1.createJsonResponse)(page);
|
|
44
|
+
}
|
|
45
|
+
return (0, helpers_1.createJsonResponse)({});
|
|
46
|
+
});
|
|
47
|
+
const client = new doover_client_1.DooverClient({
|
|
48
|
+
dataRestUrl: "https://api.example.com",
|
|
49
|
+
controlApiUrl: "https://control.example.com",
|
|
50
|
+
dataWssUrl: "wss://ws.example.com",
|
|
51
|
+
fetchImpl: fetchMock,
|
|
52
|
+
webSocketImpl: helpers_1.MockWebSocket,
|
|
53
|
+
disableBrowserLifecycleHooks: true,
|
|
54
|
+
});
|
|
55
|
+
return { client, calls };
|
|
56
|
+
}
|
|
57
|
+
/** One QueryClient per test, shared by both hooks, as a real app would have. */
|
|
58
|
+
function wrapper(client, queryClient) {
|
|
59
|
+
return function Wrapper({ children }) {
|
|
60
|
+
return ((0, jsx_runtime_1.jsx)(react_query_1.QueryClientProvider, { client: queryClient, children: (0, jsx_runtime_1.jsx)(react_2.DooverProvider, { client: client, children: children }) }));
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const newQueryClient = () => new react_query_1.QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
64
|
+
(0, mocha_1.describe)("useChannelMessages range cache", () => {
|
|
65
|
+
(0, mocha_1.beforeEach)(() => {
|
|
66
|
+
(0, helpers_1.installSessionStorageMock)();
|
|
67
|
+
(0, messageRangeStore_1.resetChannelRangeStores)();
|
|
68
|
+
});
|
|
69
|
+
(0, mocha_1.afterEach)(() => {
|
|
70
|
+
sinon_1.default.restore();
|
|
71
|
+
});
|
|
72
|
+
(0, mocha_1.it)("serves an anchored window from history already fetched, without refetching", async () => {
|
|
73
|
+
const page = messagesDesc(30, 1); // 1..30 minutes ago
|
|
74
|
+
const { client, calls } = makeClient(page);
|
|
75
|
+
const qc = newQueryClient();
|
|
76
|
+
const live = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 30 }), { wrapper: wrapper(client, qc) });
|
|
77
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(live.result.current.messages).to.have.length(30));
|
|
78
|
+
(0, chai_1.expect)(calls).to.have.length(1);
|
|
79
|
+
// Anchor inside the range just proven — a different chain, same store
|
|
80
|
+
const anchor = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - 10 * 60000));
|
|
81
|
+
const anchored = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 5, anchor }), { wrapper: wrapper(client, qc) });
|
|
82
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(anchored.result.current.messages).to.have.length(5));
|
|
83
|
+
(0, chai_1.expect)(calls, "anchoring over covered history should not hit the network").to.have.length(1);
|
|
84
|
+
(0, chai_1.expect)(anchored.result.current.messages.every((m) => BigInt(m.id) < BigInt(anchor))).to.equal(true);
|
|
85
|
+
});
|
|
86
|
+
(0, mocha_1.it)("fetches when the anchor lands outside anything proven", async () => {
|
|
87
|
+
const page = messagesDesc(30, 1);
|
|
88
|
+
const { client, calls } = makeClient(page);
|
|
89
|
+
const qc = newQueryClient();
|
|
90
|
+
const live = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 30 }), { wrapper: wrapper(client, qc) });
|
|
91
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(live.result.current.messages).to.have.length(30));
|
|
92
|
+
// A year back is nowhere near the covered range
|
|
93
|
+
const anchor = (0, snowflake_1.generateSnowflakeIdAtTime)(new Date(NOW - 365 * 24 * 60 * 60000));
|
|
94
|
+
(0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 5, anchor }), { wrapper: wrapper(client, qc) });
|
|
95
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(calls).to.have.length(2));
|
|
96
|
+
(0, chai_1.expect)(calls[1].before).to.equal(anchor);
|
|
97
|
+
});
|
|
98
|
+
(0, mocha_1.it)("keeps the unanchored key untouched, so existing consumers are unaffected", async () => {
|
|
99
|
+
const page = messagesDesc(10, 1);
|
|
100
|
+
const { client, calls } = makeClient(page);
|
|
101
|
+
const qc = newQueryClient();
|
|
102
|
+
const first = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10 }), { wrapper: wrapper(client, qc) });
|
|
103
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(first.result.current.messages).to.have.length(10));
|
|
104
|
+
// Same options => same query key => React Query serves it, no second request
|
|
105
|
+
const second = (0, react_1.renderHook)(() => (0, react_2.useChannelMessages)({ agentId: AGENT, channelName: CHANNEL }, { limit: 10 }), { wrapper: wrapper(client, qc) });
|
|
106
|
+
await (0, react_1.waitFor)(() => (0, chai_1.expect)(second.result.current.messages).to.have.length(10));
|
|
107
|
+
(0, chai_1.expect)(calls).to.have.length(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -6,9 +6,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const chai_1 = require("chai");
|
|
7
7
|
const chai_as_promised_1 = __importDefault(require("chai-as-promised"));
|
|
8
8
|
const mocha_1 = require("mocha");
|
|
9
|
+
const doover_auth_1 = require("../auth/doover-auth");
|
|
9
10
|
const rest_client_1 = require("../http/rest-client");
|
|
10
11
|
const helpers_1 = require("./helpers");
|
|
11
12
|
(0, chai_1.use)(chai_as_promised_1.default);
|
|
13
|
+
class RefreshableAuth extends doover_auth_1.DooverAuth {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.refreshCount = 0;
|
|
17
|
+
}
|
|
18
|
+
async getHttpHeaders() {
|
|
19
|
+
return { Authorization: `Bearer token-${this.refreshCount}` };
|
|
20
|
+
}
|
|
21
|
+
getFetchCredentials() {
|
|
22
|
+
return "omit";
|
|
23
|
+
}
|
|
24
|
+
async prepareWebSocket(url) {
|
|
25
|
+
return { url };
|
|
26
|
+
}
|
|
27
|
+
setToken() {
|
|
28
|
+
// no-op for test
|
|
29
|
+
}
|
|
30
|
+
setRefreshToken() {
|
|
31
|
+
// no-op for test
|
|
32
|
+
}
|
|
33
|
+
async ensureReady() {
|
|
34
|
+
// no-op for test
|
|
35
|
+
}
|
|
36
|
+
async handleUnauthorized() {
|
|
37
|
+
this.refreshCount += 1;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
12
41
|
(0, mocha_1.describe)("RestClient", () => {
|
|
13
42
|
(0, mocha_1.beforeEach)(() => {
|
|
14
43
|
(0, helpers_1.installSessionStorageMock)();
|
|
@@ -119,6 +148,42 @@ const helpers_1 = require("./helpers");
|
|
|
119
148
|
(0, chai_1.expect)(error.method).to.equal("GET");
|
|
120
149
|
});
|
|
121
150
|
});
|
|
151
|
+
(0, mocha_1.it)("refreshes auth once and retries after a 401 response", async () => {
|
|
152
|
+
const auth = new RefreshableAuth();
|
|
153
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => {
|
|
154
|
+
if (fetchMock.callCount === 1) {
|
|
155
|
+
return (0, helpers_1.createJsonResponse)({ message: "expired" }, { status: 401 });
|
|
156
|
+
}
|
|
157
|
+
return (0, helpers_1.createJsonResponse)({ ok: true });
|
|
158
|
+
});
|
|
159
|
+
const client = new rest_client_1.RestClient({
|
|
160
|
+
dataRestUrl: "https://api.example.com",
|
|
161
|
+
controlApiUrl: "https://control.example.com",
|
|
162
|
+
dataWssUrl: "wss://ws.example.com",
|
|
163
|
+
fetchImpl: fetchMock,
|
|
164
|
+
}, auth);
|
|
165
|
+
await (0, chai_1.expect)(client.get("/needs-auth")).to.eventually.deep.equal({ ok: true });
|
|
166
|
+
(0, chai_1.expect)(auth.refreshCount).to.equal(1);
|
|
167
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(2);
|
|
168
|
+
(0, chai_1.expect)((fetchMock.getCall(0).args[1]?.headers).get("Authorization")).to.equal("Bearer token-0");
|
|
169
|
+
(0, chai_1.expect)((fetchMock.getCall(1).args[1]?.headers).get("Authorization")).to.equal("Bearer token-1");
|
|
170
|
+
});
|
|
171
|
+
(0, mocha_1.it)("does not retry a 401 response when auth refresh cannot recover", async () => {
|
|
172
|
+
const auth = new RefreshableAuth();
|
|
173
|
+
auth.handleUnauthorized = async () => false;
|
|
174
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({ message: "expired" }, { status: 401 }));
|
|
175
|
+
const client = new rest_client_1.RestClient({
|
|
176
|
+
dataRestUrl: "https://api.example.com",
|
|
177
|
+
controlApiUrl: "https://control.example.com",
|
|
178
|
+
dataWssUrl: "wss://ws.example.com",
|
|
179
|
+
fetchImpl: fetchMock,
|
|
180
|
+
}, auth);
|
|
181
|
+
await (0, chai_1.expect)(client.get("/needs-auth")).to.be.rejected.then((error) => {
|
|
182
|
+
(0, chai_1.expect)(error.status).to.equal(401);
|
|
183
|
+
(0, chai_1.expect)(error.message).to.equal("expired");
|
|
184
|
+
});
|
|
185
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(1);
|
|
186
|
+
});
|
|
122
187
|
(0, mocha_1.it)("returns text bodies when the response is not JSON", async () => {
|
|
123
188
|
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createTextResponse)("plain text"));
|
|
124
189
|
const client = new rest_client_1.RestClient({
|