@gonvex/client 0.1.24 → 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 +100 -2
- package/dist/index.js +318 -16
- 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,13 +190,40 @@ 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
|
|
158
219
|
&& !sameAuthTokenIdentity(this.auth, nextAuth);
|
|
159
220
|
const scopeMayChange = tokenScopeChanged
|
|
160
221
|
|| (hasOwn(auth, "tenant") && auth.tenant !== this.auth.tenant)
|
|
161
|
-
|| (hasOwn(auth, "project") && auth.project !== this.auth.project)
|
|
222
|
+
|| (hasOwn(auth, "project") && auth.project !== this.auth.project)
|
|
223
|
+
// An identity hint that changes the derived key must recover (or drop)
|
|
224
|
+
// the warm directive just like a token change would. Same-key updates —
|
|
225
|
+
// e.g. installing the hint after its token is already live — are inert.
|
|
226
|
+
|| (hasOwn(auth, "identity") && !sameAuthTokenIdentity(this.auth, nextAuth));
|
|
162
227
|
if (scopeMayChange) {
|
|
163
228
|
this.resetQueryCacheScope();
|
|
164
229
|
}
|
|
@@ -172,9 +237,6 @@ export class GonvexClient {
|
|
|
172
237
|
if (auth.telemetry !== undefined) {
|
|
173
238
|
this.telemetryEnabled = auth.telemetry === true;
|
|
174
239
|
}
|
|
175
|
-
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
176
|
-
this.sendAuth(true);
|
|
177
|
-
}
|
|
178
240
|
}
|
|
179
241
|
connect() {
|
|
180
242
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
@@ -198,6 +260,7 @@ export class GonvexClient {
|
|
|
198
260
|
this.sendAuth(false);
|
|
199
261
|
if (isReconnect)
|
|
200
262
|
this.resubscribeQueries(generation);
|
|
263
|
+
void this.drainOutbox();
|
|
201
264
|
this.notifyConnectionState();
|
|
202
265
|
});
|
|
203
266
|
socket.addEventListener("close", () => {
|
|
@@ -254,18 +317,33 @@ export class GonvexClient {
|
|
|
254
317
|
this.authWatchdogTimer = undefined;
|
|
255
318
|
}
|
|
256
319
|
if (message.type === "auth.result") {
|
|
320
|
+
this.authRetriedAfterError = false;
|
|
257
321
|
this.installQueryCacheDirective(queryCacheDirectiveFromAuthResult(message.result));
|
|
258
322
|
this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
|
|
259
323
|
this.resumeQuerySubscriptions();
|
|
260
324
|
}
|
|
261
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;
|
|
262
338
|
this.resetQueryCacheScope();
|
|
339
|
+
this.notifyAuthError(message.error);
|
|
263
340
|
}
|
|
264
341
|
this.flushPendingMessages();
|
|
265
342
|
}
|
|
266
343
|
if (message.type === "sync.readyMany") {
|
|
267
344
|
for (const ready of message.ready) {
|
|
268
|
-
|
|
345
|
+
const readyMessage = { type: "sync.ready", ...ready };
|
|
346
|
+
this.handlers.get(ready.id)?.(readyMessage);
|
|
269
347
|
}
|
|
270
348
|
return;
|
|
271
349
|
}
|
|
@@ -301,6 +379,12 @@ export class GonvexClient {
|
|
|
301
379
|
this.querySubscribeFlushTimer = undefined;
|
|
302
380
|
}
|
|
303
381
|
this.pendingQuerySubscribes.clear();
|
|
382
|
+
if (this.outboxDrainTimer) {
|
|
383
|
+
clearTimeout(this.outboxDrainTimer);
|
|
384
|
+
this.outboxDrainTimer = undefined;
|
|
385
|
+
}
|
|
386
|
+
this.unsubscribeOutbox();
|
|
387
|
+
this.unsubscribeOverlay();
|
|
304
388
|
for (const subscription of this.querySubscriptions.values()) {
|
|
305
389
|
if (subscription.cacheReadFallbackTimer)
|
|
306
390
|
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
@@ -309,6 +393,10 @@ export class GonvexClient {
|
|
|
309
393
|
this.querySubscriptions.clear();
|
|
310
394
|
this.syncSubscriptions.clear();
|
|
311
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;
|
|
312
400
|
this.queryCacheGeneration += 1;
|
|
313
401
|
this.queryCacheDirective = undefined;
|
|
314
402
|
this.queryCache?.close();
|
|
@@ -575,8 +663,9 @@ export class GonvexClient {
|
|
|
575
663
|
existing.listeners.add(onMessage);
|
|
576
664
|
if (existing.lastMessage) {
|
|
577
665
|
queueMicrotask(() => {
|
|
578
|
-
if (existing.listeners.has(onMessage) && existing.lastMessage)
|
|
579
|
-
onMessage(existing.lastMessage);
|
|
666
|
+
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
667
|
+
onMessage(this.materializeSyncMessage(existing, existing.lastMessage));
|
|
668
|
+
}
|
|
580
669
|
});
|
|
581
670
|
}
|
|
582
671
|
return () => this.unsubscribeSyncListener(key, onMessage);
|
|
@@ -677,6 +766,7 @@ export class GonvexClient {
|
|
|
677
766
|
subscription.cursor = message.cursor;
|
|
678
767
|
subscription.keyField = message.key;
|
|
679
768
|
subscription.mode = message.mode;
|
|
769
|
+
subscription.truncated = undefined;
|
|
680
770
|
subscription.orderBy = message.orderBy;
|
|
681
771
|
subscription.orderDirection = message.orderDirection;
|
|
682
772
|
subscription.maxRows = message.maxRows;
|
|
@@ -730,6 +820,7 @@ export class GonvexClient {
|
|
|
730
820
|
subscription.verificationGeneration += 1;
|
|
731
821
|
subscription.isUpToDate = false;
|
|
732
822
|
subscription.cursor = undefined;
|
|
823
|
+
subscription.truncated = undefined;
|
|
733
824
|
subscription.rows = [];
|
|
734
825
|
subscription.persistedRows = undefined;
|
|
735
826
|
subscription.hashes = {};
|
|
@@ -825,6 +916,7 @@ export class GonvexClient {
|
|
|
825
916
|
subscription.opening = false;
|
|
826
917
|
subscription.cursor = message.cursor;
|
|
827
918
|
subscription.mode = message.mode ?? subscription.mode;
|
|
919
|
+
subscription.truncated = message.truncated;
|
|
828
920
|
subscription.integrityDigest = verifiedDigest;
|
|
829
921
|
subscription.integrityRows = subscription.rows;
|
|
830
922
|
subscription.forceFullIntegrity = false;
|
|
@@ -835,8 +927,24 @@ export class GonvexClient {
|
|
|
835
927
|
this.emitSyncMessage(subscription, message.digest === verifiedDigest ? message : { ...message, digest: verifiedDigest });
|
|
836
928
|
}
|
|
837
929
|
emitSyncMessage(subscription, message) {
|
|
930
|
+
const outgoing = this.materializeSyncMessage(subscription, message);
|
|
838
931
|
for (const listener of Array.from(subscription.listeners))
|
|
839
|
-
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
|
+
}
|
|
840
948
|
}
|
|
841
949
|
markSyncSubscriptionsOutOfDate() {
|
|
842
950
|
for (const subscription of this.syncSubscriptions.values()) {
|
|
@@ -901,6 +1009,7 @@ export class GonvexClient {
|
|
|
901
1009
|
subscription.cursor = cached.cursor;
|
|
902
1010
|
subscription.keyField = cached.keyField;
|
|
903
1011
|
subscription.mode = cached.mode;
|
|
1012
|
+
subscription.truncated = cached.truncated;
|
|
904
1013
|
subscription.orderBy = cached.orderBy;
|
|
905
1014
|
subscription.orderDirection = cached.orderDirection;
|
|
906
1015
|
subscription.maxRows = cached.maxRows;
|
|
@@ -1050,6 +1159,7 @@ export class GonvexClient {
|
|
|
1050
1159
|
cursor: subscription.cursor,
|
|
1051
1160
|
keyField: subscription.keyField,
|
|
1052
1161
|
mode: subscription.mode,
|
|
1162
|
+
truncated: subscription.truncated,
|
|
1053
1163
|
orderBy: subscription.orderBy,
|
|
1054
1164
|
orderDirection: subscription.orderDirection,
|
|
1055
1165
|
maxRows: subscription.maxRows,
|
|
@@ -1070,6 +1180,7 @@ export class GonvexClient {
|
|
|
1070
1180
|
cursor: subscription.cursor,
|
|
1071
1181
|
keyField: subscription.keyField,
|
|
1072
1182
|
mode: subscription.mode,
|
|
1183
|
+
truncated: subscription.truncated,
|
|
1073
1184
|
orderBy: subscription.orderBy,
|
|
1074
1185
|
orderDirection: subscription.orderDirection,
|
|
1075
1186
|
upserts,
|
|
@@ -1083,8 +1194,100 @@ export class GonvexClient {
|
|
|
1083
1194
|
subscription.persistedRows = subscription.rows;
|
|
1084
1195
|
this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
|
|
1085
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
|
+
}
|
|
1086
1270
|
mutation(ref, args = {}, options = {}) {
|
|
1087
|
-
|
|
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
|
+
});
|
|
1088
1291
|
}
|
|
1089
1292
|
action(ref, args = {}, options = {}) {
|
|
1090
1293
|
return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
|
|
@@ -1202,9 +1405,9 @@ export class GonvexClient {
|
|
|
1202
1405
|
this.notifyConnectionState();
|
|
1203
1406
|
return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
|
|
1204
1407
|
}
|
|
1205
|
-
call(kind, ref, args, timeoutMs) {
|
|
1408
|
+
call(kind, ref, args, timeoutMs, id) {
|
|
1206
1409
|
this.connect();
|
|
1207
|
-
const entry = this.registerCall(kind, ref, args, timeoutMs);
|
|
1410
|
+
const entry = this.registerCall(kind, ref, args, timeoutMs, id);
|
|
1208
1411
|
if (kind === "mutation") {
|
|
1209
1412
|
try {
|
|
1210
1413
|
const w = globalThis;
|
|
@@ -1220,8 +1423,8 @@ export class GonvexClient {
|
|
|
1220
1423
|
this.notifyConnectionState();
|
|
1221
1424
|
return entry.promise;
|
|
1222
1425
|
}
|
|
1223
|
-
registerCall(kind, ref, args, timeoutMs) {
|
|
1224
|
-
const id =
|
|
1426
|
+
registerCall(kind, ref, args, timeoutMs, callId = randomID()) {
|
|
1427
|
+
const id = callId;
|
|
1225
1428
|
const clientSentAtMs = nowMs();
|
|
1226
1429
|
const promise = new Promise((resolve, reject) => {
|
|
1227
1430
|
const pending = { id, kind, path: ref.path, reject };
|
|
@@ -1671,11 +1874,20 @@ export class GonvexClient {
|
|
|
1671
1874
|
device: event.device ?? browserTelemetryInfo(),
|
|
1672
1875
|
});
|
|
1673
1876
|
}
|
|
1674
|
-
sendAuth(force) {
|
|
1675
|
-
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)
|
|
1676
1879
|
return;
|
|
1677
1880
|
this.authInFlight = true;
|
|
1881
|
+
this.authRetriedAfterError = false;
|
|
1678
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() {
|
|
1679
1891
|
this.sendNow({
|
|
1680
1892
|
type: "auth",
|
|
1681
1893
|
id: randomID(),
|
|
@@ -1685,6 +1897,74 @@ export class GonvexClient {
|
|
|
1685
1897
|
device: browserTelemetryInfo(),
|
|
1686
1898
|
});
|
|
1687
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
|
+
}
|
|
1688
1968
|
// A lost auth reply (e.g. the server swapped its app plugin and dropped
|
|
1689
1969
|
// in-flight responses while the socket stayed up) used to leave
|
|
1690
1970
|
// authInFlight stuck true forever: every later mutation/subscription
|
|
@@ -1757,6 +2037,13 @@ function countPendingCalls(calls, kind) {
|
|
|
1757
2037
|
}
|
|
1758
2038
|
return count;
|
|
1759
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
|
+
}
|
|
1760
2047
|
function stableStringify(value) {
|
|
1761
2048
|
if (typeof value === "string") {
|
|
1762
2049
|
return JSON.stringify(value)
|
|
@@ -1886,6 +2173,8 @@ function authFromOptions(options) {
|
|
|
1886
2173
|
token: options.token,
|
|
1887
2174
|
tenant: options.tenant,
|
|
1888
2175
|
telemetry: options.telemetry,
|
|
2176
|
+
identity: options.identity,
|
|
2177
|
+
fetchToken: options.fetchToken,
|
|
1889
2178
|
};
|
|
1890
2179
|
}
|
|
1891
2180
|
function normalizeQuerySubscriptionRetentionMs(value) {
|
|
@@ -1896,6 +2185,19 @@ function normalizeQuerySubscriptionRetentionMs(value) {
|
|
|
1896
2185
|
return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
|
|
1897
2186
|
}
|
|
1898
2187
|
function authIdentityKey(auth) {
|
|
2188
|
+
if (!auth.tenant)
|
|
2189
|
+
return "";
|
|
2190
|
+
if (auth.token)
|
|
2191
|
+
return authIdentityKeyFromToken(auth);
|
|
2192
|
+
// Token-free fallback: an explicit identity hint carries the same claims a
|
|
2193
|
+
// token would supply, so both paths derive the same key for the same user.
|
|
2194
|
+
const hint = auth.identity;
|
|
2195
|
+
if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
|
|
2196
|
+
return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
|
|
2197
|
+
}
|
|
2198
|
+
return "";
|
|
2199
|
+
}
|
|
2200
|
+
function authIdentityKeyFromToken(auth) {
|
|
1899
2201
|
if (!auth.token || !auth.tenant)
|
|
1900
2202
|
return "";
|
|
1901
2203
|
const parts = auth.token.split(".");
|