@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.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 and are never replayed automatically.
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
- this.handlers.get(ready.id)?.({ type: "sync.ready", ...ready });
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(message);
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
- return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs);
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 = randomID();
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) {