@gonvex/client 0.1.25 → 0.1.26
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/index.d.ts +87 -2
- package/dist/index.js +299 -15
- package/dist/index.js.map +1 -1
- package/dist/optimistic.d.ts +41 -0
- package/dist/optimistic.js +98 -0
- package/dist/optimistic.js.map +1 -0
- package/dist/outbox.d.ts +86 -0
- package/dist/outbox.js +324 -0
- package/dist/outbox.js.map +1 -0
- package/dist/signals.d.ts +20 -0
- package/dist/signals.js +259 -0
- package/dist/signals.js.map +1 -0
- package/dist/sync-store.d.ts +5 -0
- package/dist/sync-store.js +4 -0
- package/dist/sync-store.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createQueryCacheStore, defaultQueryCacheReadTimeoutMs, } from "./query-cache.js";
|
|
2
2
|
import { createSyncStore, syncHashesDigest, syncRowsHashes, } from "./sync-store.js";
|
|
3
3
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
|
+
import { OptimisticOverlay } from "./optimistic.js";
|
|
5
|
+
import { createMutationOutbox, } from "./outbox.js";
|
|
4
6
|
export * from "./cache.js";
|
|
5
7
|
export * from "./cache-coordinator.js";
|
|
6
8
|
export * from "./browser-cache.js";
|
|
@@ -11,6 +13,9 @@ export * from "./persistent-cache.js";
|
|
|
11
13
|
export * from "./query-cache.js";
|
|
12
14
|
export * from "./sync-store.js";
|
|
13
15
|
export * from "./error-reporter.js";
|
|
16
|
+
export * from "./optimistic.js";
|
|
17
|
+
export * from "./outbox.js";
|
|
18
|
+
export * from "./signals.js";
|
|
14
19
|
/**
|
|
15
20
|
* Typed error for every rejected Gonvex operation. `code` distinguishes
|
|
16
21
|
* server-side failures from transport-level ones so apps can decide whether
|
|
@@ -20,7 +25,7 @@ export * from "./error-reporter.js";
|
|
|
20
25
|
* - `timeout`: no response arrived within the operation timeout. For
|
|
21
26
|
* mutations/actions the write may or may not have been applied.
|
|
22
27
|
* - `disconnected`: the socket dropped while the operation was pending.
|
|
23
|
-
* Mutations/actions fail closed
|
|
28
|
+
* Mutations/actions fail closed unless a mutation opted into the outbox.
|
|
24
29
|
* - `closed`: the client was explicitly closed.
|
|
25
30
|
* - `auth`: authentication was rejected.
|
|
26
31
|
*/
|
|
@@ -72,6 +77,14 @@ export class GonvexClient {
|
|
|
72
77
|
auth = {};
|
|
73
78
|
authInFlight = false;
|
|
74
79
|
authWatchdogTimer;
|
|
80
|
+
// Monotonic guard for async token fetches: a resolve whose generation is no
|
|
81
|
+
// longer current was superseded (newer setAuth, watchdog re-issue, or a
|
|
82
|
+
// reconnect's own fetch) and must be discarded.
|
|
83
|
+
authFetchGeneration = 0;
|
|
84
|
+
// At most one forced refresh per rejection cycle; cleared when auth settles
|
|
85
|
+
// or a fresh send cycle starts, so a bad token can't refresh-loop forever.
|
|
86
|
+
authRetriedAfterError = false;
|
|
87
|
+
authErrorHandlers = new Set();
|
|
75
88
|
telemetryEnabled = false;
|
|
76
89
|
queryCache;
|
|
77
90
|
queryCacheWaitForScope;
|
|
@@ -79,6 +92,14 @@ export class GonvexClient {
|
|
|
79
92
|
querySubscriptionRetentionMs;
|
|
80
93
|
syncSubscriptionRetentionMs;
|
|
81
94
|
syncStore;
|
|
95
|
+
mutationOutbox;
|
|
96
|
+
overlay = new OptimisticOverlay();
|
|
97
|
+
optimisticMutationIds = new Set();
|
|
98
|
+
outboxReady;
|
|
99
|
+
unsubscribeOutbox;
|
|
100
|
+
unsubscribeOverlay;
|
|
101
|
+
drainingOutbox = false;
|
|
102
|
+
outboxDrainTimer;
|
|
82
103
|
queryCacheDirective;
|
|
83
104
|
queryCacheGeneration = 0;
|
|
84
105
|
// Sync collections live under a visibility-only scope that survives query
|
|
@@ -108,6 +129,14 @@ export class GonvexClient {
|
|
|
108
129
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
109
130
|
this.syncSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.syncSubscriptionRetentionMs);
|
|
110
131
|
this.syncStore = createSyncStore(options.sync);
|
|
132
|
+
this.mutationOutbox = createMutationOutbox(options.outbox);
|
|
133
|
+
this.unsubscribeOutbox = this.mutationOutbox.subscribe(() => {
|
|
134
|
+
void this.drainOutbox();
|
|
135
|
+
});
|
|
136
|
+
this.unsubscribeOverlay = this.overlay.subscribe((collection) => {
|
|
137
|
+
this.emitOptimisticCollection(collection);
|
|
138
|
+
});
|
|
139
|
+
this.outboxReady = this.restoreOutbox();
|
|
111
140
|
this.timeouts = {
|
|
112
141
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
113
142
|
mutationTimeoutMs: options.timeouts?.mutationTimeoutMs ?? DEFAULT_MUTATION_TIMEOUT_MS,
|
|
@@ -118,6 +147,15 @@ export class GonvexClient {
|
|
|
118
147
|
}
|
|
119
148
|
this.recoverWarmSyncDirective();
|
|
120
149
|
}
|
|
150
|
+
/** The client's materialized optimistic state for pending-row indicators. */
|
|
151
|
+
get optimisticOverlay() {
|
|
152
|
+
return this.overlay;
|
|
153
|
+
}
|
|
154
|
+
/** Number of mutations waiting for a definitive server result. */
|
|
155
|
+
async outboxCount() {
|
|
156
|
+
await this.outboxReady;
|
|
157
|
+
return this.mutationOutbox.count();
|
|
158
|
+
}
|
|
121
159
|
connectionState() {
|
|
122
160
|
const inflightMutations = countPendingCalls(this.pendingCalls, "mutation");
|
|
123
161
|
const inflightActions = countPendingCalls(this.pendingCalls, "action");
|
|
@@ -152,6 +190,29 @@ export class GonvexClient {
|
|
|
152
190
|
}
|
|
153
191
|
}
|
|
154
192
|
setAuth(auth) {
|
|
193
|
+
this.applyAuth(auth);
|
|
194
|
+
// The caller owns auth now: a token fetch still in flight from the
|
|
195
|
+
// previous installation must not clobber this one when it resolves.
|
|
196
|
+
this.authFetchGeneration += 1;
|
|
197
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
198
|
+
// A token supplied in this very call was just minted by the caller —
|
|
199
|
+
// send it as-is instead of paying another fetch round trip.
|
|
200
|
+
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Subscribe to unrecoverable auth rejections: the server refused the
|
|
205
|
+
* credentials and, when a token fetcher is installed, a force-refreshed
|
|
206
|
+
* token did not fix it. Lets apps route to sign-in instead of silently
|
|
207
|
+
* degrading to an unauthenticated session.
|
|
208
|
+
*/
|
|
209
|
+
onAuthError(handler) {
|
|
210
|
+
this.authErrorHandlers.add(handler);
|
|
211
|
+
return () => {
|
|
212
|
+
this.authErrorHandlers.delete(handler);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
applyAuth(auth) {
|
|
155
216
|
const nextAuth = { ...this.auth, ...auth };
|
|
156
217
|
const tokenScopeChanged = hasOwn(auth, "token")
|
|
157
218
|
&& auth.token !== this.auth.token
|
|
@@ -176,9 +237,6 @@ export class GonvexClient {
|
|
|
176
237
|
if (auth.telemetry !== undefined) {
|
|
177
238
|
this.telemetryEnabled = auth.telemetry === true;
|
|
178
239
|
}
|
|
179
|
-
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
180
|
-
this.sendAuth(true);
|
|
181
|
-
}
|
|
182
240
|
}
|
|
183
241
|
connect() {
|
|
184
242
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
@@ -202,6 +260,7 @@ export class GonvexClient {
|
|
|
202
260
|
this.sendAuth(false);
|
|
203
261
|
if (isReconnect)
|
|
204
262
|
this.resubscribeQueries(generation);
|
|
263
|
+
void this.drainOutbox();
|
|
205
264
|
this.notifyConnectionState();
|
|
206
265
|
});
|
|
207
266
|
socket.addEventListener("close", () => {
|
|
@@ -258,18 +317,33 @@ export class GonvexClient {
|
|
|
258
317
|
this.authWatchdogTimer = undefined;
|
|
259
318
|
}
|
|
260
319
|
if (message.type === "auth.result") {
|
|
320
|
+
this.authRetriedAfterError = false;
|
|
261
321
|
this.installQueryCacheDirective(queryCacheDirectiveFromAuthResult(message.result));
|
|
262
322
|
this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
|
|
263
323
|
this.resumeQuerySubscriptions();
|
|
264
324
|
}
|
|
265
325
|
else {
|
|
326
|
+
const fetcher = this.auth.fetchToken;
|
|
327
|
+
if (fetcher && !this.authRetriedAfterError) {
|
|
328
|
+
// The installed token was rejected — typically expired while the
|
|
329
|
+
// socket was down. Force-refresh through the fetcher and retry
|
|
330
|
+
// once before treating the rejection as final.
|
|
331
|
+
this.authRetriedAfterError = true;
|
|
332
|
+
this.authInFlight = true;
|
|
333
|
+
this.armAuthWatchdog();
|
|
334
|
+
void this.refreshRejectedAuth(fetcher, this.auth.token, message.error);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.authRetriedAfterError = false;
|
|
266
338
|
this.resetQueryCacheScope();
|
|
339
|
+
this.notifyAuthError(message.error);
|
|
267
340
|
}
|
|
268
341
|
this.flushPendingMessages();
|
|
269
342
|
}
|
|
270
343
|
if (message.type === "sync.readyMany") {
|
|
271
344
|
for (const ready of message.ready) {
|
|
272
|
-
|
|
345
|
+
const readyMessage = { type: "sync.ready", ...ready };
|
|
346
|
+
this.handlers.get(ready.id)?.(readyMessage);
|
|
273
347
|
}
|
|
274
348
|
return;
|
|
275
349
|
}
|
|
@@ -305,6 +379,12 @@ export class GonvexClient {
|
|
|
305
379
|
this.querySubscribeFlushTimer = undefined;
|
|
306
380
|
}
|
|
307
381
|
this.pendingQuerySubscribes.clear();
|
|
382
|
+
if (this.outboxDrainTimer) {
|
|
383
|
+
clearTimeout(this.outboxDrainTimer);
|
|
384
|
+
this.outboxDrainTimer = undefined;
|
|
385
|
+
}
|
|
386
|
+
this.unsubscribeOutbox();
|
|
387
|
+
this.unsubscribeOverlay();
|
|
308
388
|
for (const subscription of this.querySubscriptions.values()) {
|
|
309
389
|
if (subscription.cacheReadFallbackTimer)
|
|
310
390
|
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
@@ -313,6 +393,10 @@ export class GonvexClient {
|
|
|
313
393
|
this.querySubscriptions.clear();
|
|
314
394
|
this.syncSubscriptions.clear();
|
|
315
395
|
this.sessionScopeHandlers.clear();
|
|
396
|
+
this.authErrorHandlers.clear();
|
|
397
|
+
// Invalidate any token fetch still in flight so its resolve can't touch
|
|
398
|
+
// the closed client's caches.
|
|
399
|
+
this.authFetchGeneration += 1;
|
|
316
400
|
this.queryCacheGeneration += 1;
|
|
317
401
|
this.queryCacheDirective = undefined;
|
|
318
402
|
this.queryCache?.close();
|
|
@@ -579,8 +663,9 @@ export class GonvexClient {
|
|
|
579
663
|
existing.listeners.add(onMessage);
|
|
580
664
|
if (existing.lastMessage) {
|
|
581
665
|
queueMicrotask(() => {
|
|
582
|
-
if (existing.listeners.has(onMessage) && existing.lastMessage)
|
|
583
|
-
onMessage(existing.lastMessage);
|
|
666
|
+
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
667
|
+
onMessage(this.materializeSyncMessage(existing, existing.lastMessage));
|
|
668
|
+
}
|
|
584
669
|
});
|
|
585
670
|
}
|
|
586
671
|
return () => this.unsubscribeSyncListener(key, onMessage);
|
|
@@ -681,6 +766,7 @@ export class GonvexClient {
|
|
|
681
766
|
subscription.cursor = message.cursor;
|
|
682
767
|
subscription.keyField = message.key;
|
|
683
768
|
subscription.mode = message.mode;
|
|
769
|
+
subscription.truncated = undefined;
|
|
684
770
|
subscription.orderBy = message.orderBy;
|
|
685
771
|
subscription.orderDirection = message.orderDirection;
|
|
686
772
|
subscription.maxRows = message.maxRows;
|
|
@@ -734,6 +820,7 @@ export class GonvexClient {
|
|
|
734
820
|
subscription.verificationGeneration += 1;
|
|
735
821
|
subscription.isUpToDate = false;
|
|
736
822
|
subscription.cursor = undefined;
|
|
823
|
+
subscription.truncated = undefined;
|
|
737
824
|
subscription.rows = [];
|
|
738
825
|
subscription.persistedRows = undefined;
|
|
739
826
|
subscription.hashes = {};
|
|
@@ -829,6 +916,7 @@ export class GonvexClient {
|
|
|
829
916
|
subscription.opening = false;
|
|
830
917
|
subscription.cursor = message.cursor;
|
|
831
918
|
subscription.mode = message.mode ?? subscription.mode;
|
|
919
|
+
subscription.truncated = message.truncated;
|
|
832
920
|
subscription.integrityDigest = verifiedDigest;
|
|
833
921
|
subscription.integrityRows = subscription.rows;
|
|
834
922
|
subscription.forceFullIntegrity = false;
|
|
@@ -839,8 +927,24 @@ export class GonvexClient {
|
|
|
839
927
|
this.emitSyncMessage(subscription, message.digest === verifiedDigest ? message : { ...message, digest: verifiedDigest });
|
|
840
928
|
}
|
|
841
929
|
emitSyncMessage(subscription, message) {
|
|
930
|
+
const outgoing = this.materializeSyncMessage(subscription, message);
|
|
842
931
|
for (const listener of Array.from(subscription.listeners))
|
|
843
|
-
listener(
|
|
932
|
+
listener(outgoing);
|
|
933
|
+
}
|
|
934
|
+
materializeSyncMessage(subscription, message) {
|
|
935
|
+
if (message.type !== "sync.snapshot")
|
|
936
|
+
return message;
|
|
937
|
+
return {
|
|
938
|
+
...message,
|
|
939
|
+
result: this.overlay.apply(subscription.path, message.result, message.key),
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
emitOptimisticCollection(collection) {
|
|
943
|
+
for (const subscription of this.syncSubscriptions.values()) {
|
|
944
|
+
if (subscription.path !== collection || subscription.lastMessage?.type !== "sync.snapshot")
|
|
945
|
+
continue;
|
|
946
|
+
this.emitSyncMessage(subscription, subscription.lastMessage);
|
|
947
|
+
}
|
|
844
948
|
}
|
|
845
949
|
markSyncSubscriptionsOutOfDate() {
|
|
846
950
|
for (const subscription of this.syncSubscriptions.values()) {
|
|
@@ -905,6 +1009,7 @@ export class GonvexClient {
|
|
|
905
1009
|
subscription.cursor = cached.cursor;
|
|
906
1010
|
subscription.keyField = cached.keyField;
|
|
907
1011
|
subscription.mode = cached.mode;
|
|
1012
|
+
subscription.truncated = cached.truncated;
|
|
908
1013
|
subscription.orderBy = cached.orderBy;
|
|
909
1014
|
subscription.orderDirection = cached.orderDirection;
|
|
910
1015
|
subscription.maxRows = cached.maxRows;
|
|
@@ -1054,6 +1159,7 @@ export class GonvexClient {
|
|
|
1054
1159
|
cursor: subscription.cursor,
|
|
1055
1160
|
keyField: subscription.keyField,
|
|
1056
1161
|
mode: subscription.mode,
|
|
1162
|
+
truncated: subscription.truncated,
|
|
1057
1163
|
orderBy: subscription.orderBy,
|
|
1058
1164
|
orderDirection: subscription.orderDirection,
|
|
1059
1165
|
maxRows: subscription.maxRows,
|
|
@@ -1074,6 +1180,7 @@ export class GonvexClient {
|
|
|
1074
1180
|
cursor: subscription.cursor,
|
|
1075
1181
|
keyField: subscription.keyField,
|
|
1076
1182
|
mode: subscription.mode,
|
|
1183
|
+
truncated: subscription.truncated,
|
|
1077
1184
|
orderBy: subscription.orderBy,
|
|
1078
1185
|
orderDirection: subscription.orderDirection,
|
|
1079
1186
|
upserts,
|
|
@@ -1087,8 +1194,100 @@ export class GonvexClient {
|
|
|
1087
1194
|
subscription.persistedRows = subscription.rows;
|
|
1088
1195
|
this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
|
|
1089
1196
|
}
|
|
1197
|
+
async restoreOutbox() {
|
|
1198
|
+
const entries = await this.mutationOutbox.loadAll();
|
|
1199
|
+
if (this.manuallyClosed)
|
|
1200
|
+
return;
|
|
1201
|
+
for (const entry of entries) {
|
|
1202
|
+
this.addOptimisticMutation(entry.idempotencyKey, entry.patches ?? []);
|
|
1203
|
+
}
|
|
1204
|
+
const nextAttemptAt = Math.min(...entries
|
|
1205
|
+
.filter((entry) => entry.state === "pending")
|
|
1206
|
+
.map((entry) => entry.nextAttemptAt));
|
|
1207
|
+
if (Number.isFinite(nextAttemptAt) && nextAttemptAt > Date.now()) {
|
|
1208
|
+
this.scheduleOutboxDrain(nextAttemptAt - Date.now());
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
addOptimisticMutation(mutationId, patches) {
|
|
1212
|
+
if (patches.length === 0 || this.optimisticMutationIds.has(mutationId))
|
|
1213
|
+
return;
|
|
1214
|
+
this.optimisticMutationIds.add(mutationId);
|
|
1215
|
+
this.overlay.add(mutationId, patches);
|
|
1216
|
+
}
|
|
1217
|
+
settleOptimisticMutation(mutationId) {
|
|
1218
|
+
this.optimisticMutationIds.delete(mutationId);
|
|
1219
|
+
this.overlay.settle(mutationId);
|
|
1220
|
+
}
|
|
1221
|
+
rejectOptimisticMutation(mutationId) {
|
|
1222
|
+
this.optimisticMutationIds.delete(mutationId);
|
|
1223
|
+
this.overlay.reject(mutationId);
|
|
1224
|
+
}
|
|
1225
|
+
async drainOutbox() {
|
|
1226
|
+
await this.outboxReady;
|
|
1227
|
+
if (this.drainingOutbox
|
|
1228
|
+
|| this.manuallyClosed
|
|
1229
|
+
|| !this.socket
|
|
1230
|
+
|| this.socket.readyState !== WebSocket.OPEN)
|
|
1231
|
+
return;
|
|
1232
|
+
this.drainingOutbox = true;
|
|
1233
|
+
try {
|
|
1234
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
1235
|
+
const entry = await this.mutationOutbox.nextReady(Date.now());
|
|
1236
|
+
if (!entry)
|
|
1237
|
+
return;
|
|
1238
|
+
await this.mutationOutbox.markInflight(entry.id);
|
|
1239
|
+
try {
|
|
1240
|
+
await this.call("mutation", { kind: "mutation", path: entry.path }, entry.args, this.timeouts.mutationTimeoutMs, entry.idempotencyKey);
|
|
1241
|
+
await this.mutationOutbox.ack(entry.id);
|
|
1242
|
+
this.settleOptimisticMutation(entry.idempotencyKey);
|
|
1243
|
+
}
|
|
1244
|
+
catch (error) {
|
|
1245
|
+
if (error instanceof GonvexClientError && error.code === "server") {
|
|
1246
|
+
await this.mutationOutbox.ack(entry.id);
|
|
1247
|
+
this.rejectOptimisticMutation(entry.idempotencyKey);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
await this.mutationOutbox.fail(entry.id, mutationErrorMessage(error));
|
|
1251
|
+
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
this.drainingOutbox = false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
scheduleOutboxDrain(delay) {
|
|
1261
|
+
if (this.manuallyClosed)
|
|
1262
|
+
return;
|
|
1263
|
+
if (this.outboxDrainTimer)
|
|
1264
|
+
clearTimeout(this.outboxDrainTimer);
|
|
1265
|
+
this.outboxDrainTimer = setTimeout(() => {
|
|
1266
|
+
this.outboxDrainTimer = undefined;
|
|
1267
|
+
void this.drainOutbox();
|
|
1268
|
+
}, delay);
|
|
1269
|
+
}
|
|
1090
1270
|
mutation(ref, args = {}, options = {}) {
|
|
1091
|
-
|
|
1271
|
+
const mutationId = randomID();
|
|
1272
|
+
const patches = options.optimistic ?? [];
|
|
1273
|
+
this.addOptimisticMutation(mutationId, patches);
|
|
1274
|
+
return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId).then((result) => {
|
|
1275
|
+
this.settleOptimisticMutation(mutationId);
|
|
1276
|
+
return result;
|
|
1277
|
+
}).catch(async (error) => {
|
|
1278
|
+
if (isQueueableMutationError(error) && options.offline === "queue") {
|
|
1279
|
+
await this.mutationOutbox.enqueue({
|
|
1280
|
+
path: ref.path,
|
|
1281
|
+
args,
|
|
1282
|
+
idempotencyKey: mutationId,
|
|
1283
|
+
entityKeys: patches.map((patch) => patch.rowId),
|
|
1284
|
+
patches,
|
|
1285
|
+
});
|
|
1286
|
+
return { status: "queued", mutationId };
|
|
1287
|
+
}
|
|
1288
|
+
this.rejectOptimisticMutation(mutationId);
|
|
1289
|
+
throw error;
|
|
1290
|
+
});
|
|
1092
1291
|
}
|
|
1093
1292
|
action(ref, args = {}, options = {}) {
|
|
1094
1293
|
return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
|
|
@@ -1206,9 +1405,9 @@ export class GonvexClient {
|
|
|
1206
1405
|
this.notifyConnectionState();
|
|
1207
1406
|
return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
|
|
1208
1407
|
}
|
|
1209
|
-
call(kind, ref, args, timeoutMs) {
|
|
1408
|
+
call(kind, ref, args, timeoutMs, id) {
|
|
1210
1409
|
this.connect();
|
|
1211
|
-
const entry = this.registerCall(kind, ref, args, timeoutMs);
|
|
1410
|
+
const entry = this.registerCall(kind, ref, args, timeoutMs, id);
|
|
1212
1411
|
if (kind === "mutation") {
|
|
1213
1412
|
try {
|
|
1214
1413
|
const w = globalThis;
|
|
@@ -1224,8 +1423,8 @@ export class GonvexClient {
|
|
|
1224
1423
|
this.notifyConnectionState();
|
|
1225
1424
|
return entry.promise;
|
|
1226
1425
|
}
|
|
1227
|
-
registerCall(kind, ref, args, timeoutMs) {
|
|
1228
|
-
const id =
|
|
1426
|
+
registerCall(kind, ref, args, timeoutMs, callId = randomID()) {
|
|
1427
|
+
const id = callId;
|
|
1229
1428
|
const clientSentAtMs = nowMs();
|
|
1230
1429
|
const promise = new Promise((resolve, reject) => {
|
|
1231
1430
|
const pending = { id, kind, path: ref.path, reject };
|
|
@@ -1675,11 +1874,20 @@ export class GonvexClient {
|
|
|
1675
1874
|
device: event.device ?? browserTelemetryInfo(),
|
|
1676
1875
|
});
|
|
1677
1876
|
}
|
|
1678
|
-
sendAuth(force) {
|
|
1679
|
-
if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project)
|
|
1877
|
+
sendAuth(force, options = {}) {
|
|
1878
|
+
if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project && !this.auth.fetchToken)
|
|
1680
1879
|
return;
|
|
1681
1880
|
this.authInFlight = true;
|
|
1881
|
+
this.authRetriedAfterError = false;
|
|
1682
1882
|
this.armAuthWatchdog();
|
|
1883
|
+
const fetcher = this.auth.fetchToken;
|
|
1884
|
+
if (fetcher && options.useFetcher !== false) {
|
|
1885
|
+
void this.fetchAndSendAuth(fetcher);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
this.sendAuthFrame();
|
|
1889
|
+
}
|
|
1890
|
+
sendAuthFrame() {
|
|
1683
1891
|
this.sendNow({
|
|
1684
1892
|
type: "auth",
|
|
1685
1893
|
id: randomID(),
|
|
@@ -1689,6 +1897,74 @@ export class GonvexClient {
|
|
|
1689
1897
|
device: browserTelemetryInfo(),
|
|
1690
1898
|
});
|
|
1691
1899
|
}
|
|
1900
|
+
// Tokens from a fetcher are typically short-lived while the socket (and any
|
|
1901
|
+
// disconnect gap) can span hours: replaying the token that was current at
|
|
1902
|
+
// setAuth time guarantees an auth.error after a long sleep. authInFlight is
|
|
1903
|
+
// already true here, so everything else queues behind the fetch exactly as
|
|
1904
|
+
// it queues behind the server's auth reply.
|
|
1905
|
+
async fetchAndSendAuth(fetcher) {
|
|
1906
|
+
const generation = ++this.authFetchGeneration;
|
|
1907
|
+
const socket = this.socket;
|
|
1908
|
+
let token;
|
|
1909
|
+
try {
|
|
1910
|
+
token = await fetcher({ forceRefreshToken: false });
|
|
1911
|
+
}
|
|
1912
|
+
catch {
|
|
1913
|
+
// A fetcher that cannot reach its identity provider (offline start)
|
|
1914
|
+
// must not sign the session out — fall back to the installed token.
|
|
1915
|
+
token = undefined;
|
|
1916
|
+
}
|
|
1917
|
+
if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
|
|
1918
|
+
return;
|
|
1919
|
+
if (typeof token === "string" && token) {
|
|
1920
|
+
this.applyAuth({ token });
|
|
1921
|
+
}
|
|
1922
|
+
else if (token === null) {
|
|
1923
|
+
// The fetcher is authoritative about sign-out.
|
|
1924
|
+
this.applyAuth({ token: undefined });
|
|
1925
|
+
}
|
|
1926
|
+
// A dead socket's close handler already reset authInFlight; the next
|
|
1927
|
+
// reconnect runs its own sendAuth, so this resolve has nothing to send.
|
|
1928
|
+
if (this.socket !== socket || socket?.readyState !== WebSocket.OPEN)
|
|
1929
|
+
return;
|
|
1930
|
+
this.sendAuthFrame();
|
|
1931
|
+
}
|
|
1932
|
+
async refreshRejectedAuth(fetcher, rejectedToken, error) {
|
|
1933
|
+
const generation = ++this.authFetchGeneration;
|
|
1934
|
+
const socket = this.socket;
|
|
1935
|
+
let token;
|
|
1936
|
+
try {
|
|
1937
|
+
token = await fetcher({ forceRefreshToken: true });
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
token = undefined;
|
|
1941
|
+
}
|
|
1942
|
+
if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
|
|
1943
|
+
return;
|
|
1944
|
+
if (typeof token === "string" && token && token !== rejectedToken) {
|
|
1945
|
+
this.applyAuth({ token });
|
|
1946
|
+
if (this.socket === socket && socket?.readyState === WebSocket.OPEN) {
|
|
1947
|
+
this.sendAuthFrame();
|
|
1948
|
+
}
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
// No fresher credential exists (fetch failed, signed out, or the refresh
|
|
1952
|
+
// returned the very token the server just refused): surface the rejection
|
|
1953
|
+
// and degrade to the unauthenticated flow exactly like the no-fetcher path.
|
|
1954
|
+
this.authInFlight = false;
|
|
1955
|
+
if (this.authWatchdogTimer) {
|
|
1956
|
+
clearTimeout(this.authWatchdogTimer);
|
|
1957
|
+
this.authWatchdogTimer = undefined;
|
|
1958
|
+
}
|
|
1959
|
+
this.resetQueryCacheScope();
|
|
1960
|
+
this.notifyAuthError(error);
|
|
1961
|
+
this.flushPendingMessages();
|
|
1962
|
+
}
|
|
1963
|
+
notifyAuthError(error) {
|
|
1964
|
+
for (const handler of Array.from(this.authErrorHandlers)) {
|
|
1965
|
+
handler(error);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1692
1968
|
// A lost auth reply (e.g. the server swapped its app plugin and dropped
|
|
1693
1969
|
// in-flight responses while the socket stayed up) used to leave
|
|
1694
1970
|
// authInFlight stuck true forever: every later mutation/subscription
|
|
@@ -1761,6 +2037,13 @@ function countPendingCalls(calls, kind) {
|
|
|
1761
2037
|
}
|
|
1762
2038
|
return count;
|
|
1763
2039
|
}
|
|
2040
|
+
function isQueueableMutationError(error) {
|
|
2041
|
+
return error instanceof GonvexClientError
|
|
2042
|
+
&& (error.code === "disconnected" || error.code === "timeout");
|
|
2043
|
+
}
|
|
2044
|
+
function mutationErrorMessage(error) {
|
|
2045
|
+
return error instanceof Error ? error.message : String(error);
|
|
2046
|
+
}
|
|
1764
2047
|
function stableStringify(value) {
|
|
1765
2048
|
if (typeof value === "string") {
|
|
1766
2049
|
return JSON.stringify(value)
|
|
@@ -1891,6 +2174,7 @@ function authFromOptions(options) {
|
|
|
1891
2174
|
tenant: options.tenant,
|
|
1892
2175
|
telemetry: options.telemetry,
|
|
1893
2176
|
identity: options.identity,
|
|
2177
|
+
fetchToken: options.fetchToken,
|
|
1894
2178
|
};
|
|
1895
2179
|
}
|
|
1896
2180
|
function normalizeQuerySubscriptionRetentionMs(value) {
|