@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.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,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
- this.handlers.get(ready.id)?.({ type: "sync.ready", ...ready });
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(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
+ }
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
- 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
+ });
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 = randomID();
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(".");