@gonvex/client 0.1.25 → 0.1.27

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
  */
@@ -54,6 +59,9 @@ const maxSyncBatchOpens = 256;
54
59
  // start into a cold open — never into a permanently empty screen. Reads
55
60
  // normally settle in a few milliseconds.
56
61
  const syncStoreReadTimeoutMs = 1_000;
62
+ // Watermarks can arrive for every tenant revision. Bound cursor-only IndexedDB
63
+ // writes per collection while keeping the in-memory resume cursor immediate.
64
+ const syncWatermarkPersistDelayMs = 1_000;
57
65
  export class GonvexClient {
58
66
  url;
59
67
  socket;
@@ -72,6 +80,14 @@ export class GonvexClient {
72
80
  auth = {};
73
81
  authInFlight = false;
74
82
  authWatchdogTimer;
83
+ // Monotonic guard for async token fetches: a resolve whose generation is no
84
+ // longer current was superseded (newer setAuth, watchdog re-issue, or a
85
+ // reconnect's own fetch) and must be discarded.
86
+ authFetchGeneration = 0;
87
+ // At most one forced refresh per rejection cycle; cleared when auth settles
88
+ // or a fresh send cycle starts, so a bad token can't refresh-loop forever.
89
+ authRetriedAfterError = false;
90
+ authErrorHandlers = new Set();
75
91
  telemetryEnabled = false;
76
92
  queryCache;
77
93
  queryCacheWaitForScope;
@@ -79,6 +95,14 @@ export class GonvexClient {
79
95
  querySubscriptionRetentionMs;
80
96
  syncSubscriptionRetentionMs;
81
97
  syncStore;
98
+ mutationOutbox;
99
+ overlay = new OptimisticOverlay();
100
+ optimisticMutationIds = new Set();
101
+ outboxReady;
102
+ unsubscribeOutbox;
103
+ unsubscribeOverlay;
104
+ drainingOutbox = false;
105
+ outboxDrainTimer;
82
106
  queryCacheDirective;
83
107
  queryCacheGeneration = 0;
84
108
  // Sync collections live under a visibility-only scope that survives query
@@ -108,6 +132,14 @@ export class GonvexClient {
108
132
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
109
133
  this.syncSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.syncSubscriptionRetentionMs);
110
134
  this.syncStore = createSyncStore(options.sync);
135
+ this.mutationOutbox = createMutationOutbox(options.outbox);
136
+ this.unsubscribeOutbox = this.mutationOutbox.subscribe(() => {
137
+ void this.drainOutbox();
138
+ });
139
+ this.unsubscribeOverlay = this.overlay.subscribe((collection) => {
140
+ this.emitOptimisticCollection(collection);
141
+ });
142
+ this.outboxReady = this.restoreOutbox();
111
143
  this.timeouts = {
112
144
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
113
145
  mutationTimeoutMs: options.timeouts?.mutationTimeoutMs ?? DEFAULT_MUTATION_TIMEOUT_MS,
@@ -118,6 +150,15 @@ export class GonvexClient {
118
150
  }
119
151
  this.recoverWarmSyncDirective();
120
152
  }
153
+ /** The client's materialized optimistic state for pending-row indicators. */
154
+ get optimisticOverlay() {
155
+ return this.overlay;
156
+ }
157
+ /** Number of mutations waiting for a definitive server result. */
158
+ async outboxCount() {
159
+ await this.outboxReady;
160
+ return this.mutationOutbox.count();
161
+ }
121
162
  connectionState() {
122
163
  const inflightMutations = countPendingCalls(this.pendingCalls, "mutation");
123
164
  const inflightActions = countPendingCalls(this.pendingCalls, "action");
@@ -152,6 +193,29 @@ export class GonvexClient {
152
193
  }
153
194
  }
154
195
  setAuth(auth) {
196
+ this.applyAuth(auth);
197
+ // The caller owns auth now: a token fetch still in flight from the
198
+ // previous installation must not clobber this one when it resolves.
199
+ this.authFetchGeneration += 1;
200
+ if (this.socket?.readyState === WebSocket.OPEN) {
201
+ // A token supplied in this very call was just minted by the caller —
202
+ // send it as-is instead of paying another fetch round trip.
203
+ this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
204
+ }
205
+ }
206
+ /**
207
+ * Subscribe to unrecoverable auth rejections: the server refused the
208
+ * credentials and, when a token fetcher is installed, a force-refreshed
209
+ * token did not fix it. Lets apps route to sign-in instead of silently
210
+ * degrading to an unauthenticated session.
211
+ */
212
+ onAuthError(handler) {
213
+ this.authErrorHandlers.add(handler);
214
+ return () => {
215
+ this.authErrorHandlers.delete(handler);
216
+ };
217
+ }
218
+ applyAuth(auth) {
155
219
  const nextAuth = { ...this.auth, ...auth };
156
220
  const tokenScopeChanged = hasOwn(auth, "token")
157
221
  && auth.token !== this.auth.token
@@ -176,9 +240,6 @@ export class GonvexClient {
176
240
  if (auth.telemetry !== undefined) {
177
241
  this.telemetryEnabled = auth.telemetry === true;
178
242
  }
179
- if (this.socket?.readyState === WebSocket.OPEN) {
180
- this.sendAuth(true);
181
- }
182
243
  }
183
244
  connect() {
184
245
  if (this.socket && this.socket.readyState <= WebSocket.OPEN)
@@ -202,6 +263,7 @@ export class GonvexClient {
202
263
  this.sendAuth(false);
203
264
  if (isReconnect)
204
265
  this.resubscribeQueries(generation);
266
+ void this.drainOutbox();
205
267
  this.notifyConnectionState();
206
268
  });
207
269
  socket.addEventListener("close", () => {
@@ -258,18 +320,39 @@ export class GonvexClient {
258
320
  this.authWatchdogTimer = undefined;
259
321
  }
260
322
  if (message.type === "auth.result") {
323
+ this.authRetriedAfterError = false;
261
324
  this.installQueryCacheDirective(queryCacheDirectiveFromAuthResult(message.result));
262
325
  this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
263
326
  this.resumeQuerySubscriptions();
264
327
  }
265
328
  else {
329
+ const fetcher = this.auth.fetchToken;
330
+ if (fetcher && !this.authRetriedAfterError) {
331
+ // The installed token was rejected — typically expired while the
332
+ // socket was down. Force-refresh through the fetcher and retry
333
+ // once before treating the rejection as final.
334
+ this.authRetriedAfterError = true;
335
+ this.authInFlight = true;
336
+ this.armAuthWatchdog();
337
+ void this.refreshRejectedAuth(fetcher, this.auth.token, message.error);
338
+ return;
339
+ }
340
+ this.authRetriedAfterError = false;
266
341
  this.resetQueryCacheScope();
342
+ this.notifyAuthError(message.error);
267
343
  }
268
344
  this.flushPendingMessages();
269
345
  }
270
346
  if (message.type === "sync.readyMany") {
271
347
  for (const ready of message.ready) {
272
- this.handlers.get(ready.id)?.({ type: "sync.ready", ...ready });
348
+ const readyMessage = { type: "sync.ready", ...ready };
349
+ this.handlers.get(ready.id)?.(readyMessage);
350
+ }
351
+ return;
352
+ }
353
+ if (message.type === "sync.watermark") {
354
+ if (this.serverCapabilities.syncWatermark === 1) {
355
+ this.handleSyncWatermark(message.revision);
273
356
  }
274
357
  return;
275
358
  }
@@ -294,6 +377,11 @@ export class GonvexClient {
294
377
  this.clearSyncRetry(subscription);
295
378
  if (subscription.unsubscribeTimer)
296
379
  clearTimeout(subscription.unsubscribeTimer);
380
+ if (subscription.watermarkPersistTimer) {
381
+ clearTimeout(subscription.watermarkPersistTimer);
382
+ subscription.watermarkPersistTimer = undefined;
383
+ this.persistSyncSnapshot(subscription, true);
384
+ }
297
385
  }
298
386
  if (this.syncOpenFlushTimer) {
299
387
  clearTimeout(this.syncOpenFlushTimer);
@@ -305,6 +393,12 @@ export class GonvexClient {
305
393
  this.querySubscribeFlushTimer = undefined;
306
394
  }
307
395
  this.pendingQuerySubscribes.clear();
396
+ if (this.outboxDrainTimer) {
397
+ clearTimeout(this.outboxDrainTimer);
398
+ this.outboxDrainTimer = undefined;
399
+ }
400
+ this.unsubscribeOutbox();
401
+ this.unsubscribeOverlay();
308
402
  for (const subscription of this.querySubscriptions.values()) {
309
403
  if (subscription.cacheReadFallbackTimer)
310
404
  clearTimeout(subscription.cacheReadFallbackTimer);
@@ -313,6 +407,10 @@ export class GonvexClient {
313
407
  this.querySubscriptions.clear();
314
408
  this.syncSubscriptions.clear();
315
409
  this.sessionScopeHandlers.clear();
410
+ this.authErrorHandlers.clear();
411
+ // Invalidate any token fetch still in flight so its resolve can't touch
412
+ // the closed client's caches.
413
+ this.authFetchGeneration += 1;
316
414
  this.queryCacheGeneration += 1;
317
415
  this.queryCacheDirective = undefined;
318
416
  this.queryCache?.close();
@@ -579,8 +677,9 @@ export class GonvexClient {
579
677
  existing.listeners.add(onMessage);
580
678
  if (existing.lastMessage) {
581
679
  queueMicrotask(() => {
582
- if (existing.listeners.has(onMessage) && existing.lastMessage)
583
- onMessage(existing.lastMessage);
680
+ if (existing.listeners.has(onMessage) && existing.lastMessage) {
681
+ onMessage(this.materializeSyncMessage(existing, existing.lastMessage));
682
+ }
584
683
  });
585
684
  }
586
685
  return () => this.unsubscribeSyncListener(key, onMessage);
@@ -681,6 +780,7 @@ export class GonvexClient {
681
780
  subscription.cursor = message.cursor;
682
781
  subscription.keyField = message.key;
683
782
  subscription.mode = message.mode;
783
+ subscription.truncated = undefined;
684
784
  subscription.orderBy = message.orderBy;
685
785
  subscription.orderDirection = message.orderDirection;
686
786
  subscription.maxRows = message.maxRows;
@@ -689,6 +789,7 @@ export class GonvexClient {
689
789
  subscription.hashes = { ...(message.hashes ?? {}) };
690
790
  subscription.integrityDigest = undefined;
691
791
  subscription.integrityRows = undefined;
792
+ subscription.integrityEpoch = undefined;
692
793
  const snapshot = { ...message, result: subscription.rows };
693
794
  subscription.lastMessage = snapshot;
694
795
  this.emitSyncMessage(subscription, snapshot);
@@ -711,6 +812,7 @@ export class GonvexClient {
711
812
  Object.assign(subscription.hashes, message.hashes ?? {});
712
813
  subscription.integrityDigest = undefined;
713
814
  subscription.integrityRows = undefined;
815
+ subscription.integrityEpoch = undefined;
714
816
  const snapshot = {
715
817
  type: "sync.snapshot",
716
818
  id: subscription.id,
@@ -731,14 +833,20 @@ export class GonvexClient {
731
833
  }
732
834
  if (message.type === "sync.reset") {
733
835
  this.clearSyncRetry(subscription, true);
836
+ if (subscription.watermarkPersistTimer) {
837
+ clearTimeout(subscription.watermarkPersistTimer);
838
+ subscription.watermarkPersistTimer = undefined;
839
+ }
734
840
  subscription.verificationGeneration += 1;
735
841
  subscription.isUpToDate = false;
736
842
  subscription.cursor = undefined;
843
+ subscription.truncated = undefined;
737
844
  subscription.rows = [];
738
845
  subscription.persistedRows = undefined;
739
846
  subscription.hashes = {};
740
847
  subscription.integrityDigest = undefined;
741
848
  subscription.integrityRows = undefined;
849
+ subscription.integrityEpoch = undefined;
742
850
  subscription.forceFullIntegrity = false;
743
851
  subscription.lastMessage = undefined;
744
852
  subscription.opening = false;
@@ -786,6 +894,20 @@ export class GonvexClient {
786
894
  });
787
895
  return;
788
896
  }
897
+ if (!subscription.forceFullIntegrity
898
+ && subscription.integrityRows === subscription.rows
899
+ && subscription.integrityDigest
900
+ && subscription.integrityEpoch === subscription.cursor.epoch) {
901
+ if (message.digest && subscription.integrityDigest !== message.digest) {
902
+ // Re-hash once before treating the server/memo disagreement as an
903
+ // integrity failure. The memo may be stale even though row identity
904
+ // says the collection has not changed.
905
+ }
906
+ else {
907
+ this.acceptSyncReady(subscription, message, subscription.integrityDigest);
908
+ return;
909
+ }
910
+ }
789
911
  void syncRowsHashes(subscription.rows, subscription.keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ digest, hashes })))).then(({ digest, hashes }) => {
790
912
  if (generation !== subscription.verificationGeneration
791
913
  || this.syncSubscriptions.get(subscription.key) !== subscription)
@@ -829,8 +951,10 @@ export class GonvexClient {
829
951
  subscription.opening = false;
830
952
  subscription.cursor = message.cursor;
831
953
  subscription.mode = message.mode ?? subscription.mode;
954
+ subscription.truncated = message.truncated;
832
955
  subscription.integrityDigest = verifiedDigest;
833
956
  subscription.integrityRows = subscription.rows;
957
+ subscription.integrityEpoch = message.cursor.epoch;
834
958
  subscription.forceFullIntegrity = false;
835
959
  this.persistSyncSnapshot(subscription);
836
960
  // Every emitted ready frame is self-describing: when a legacy runtime
@@ -838,9 +962,53 @@ export class GonvexClient {
838
962
  // observe one contract regardless of the peer's protocol generation.
839
963
  this.emitSyncMessage(subscription, message.digest === verifiedDigest ? message : { ...message, digest: verifiedDigest });
840
964
  }
965
+ handleSyncWatermark(revision) {
966
+ if (!Number.isSafeInteger(revision) || revision < 0)
967
+ return;
968
+ for (const subscription of this.syncSubscriptions.values()) {
969
+ const cursor = subscription.cursor;
970
+ if (!cursor
971
+ || cursor.revision >= revision
972
+ || !subscription.isUpToDate
973
+ || subscription.opening
974
+ || subscription.forceFullIntegrity
975
+ || subscription.integrityRows !== subscription.rows
976
+ || !subscription.integrityDigest
977
+ || subscription.integrityEpoch !== cursor.epoch)
978
+ continue;
979
+ subscription.cursor = { ...cursor, revision };
980
+ this.scheduleSyncWatermarkPersistence(subscription);
981
+ }
982
+ }
983
+ scheduleSyncWatermarkPersistence(subscription) {
984
+ if (subscription.watermarkPersistTimer)
985
+ return;
986
+ subscription.watermarkPersistTimer = setTimeout(() => {
987
+ subscription.watermarkPersistTimer = undefined;
988
+ if (this.syncSubscriptions.get(subscription.key) !== subscription)
989
+ return;
990
+ this.persistSyncSnapshot(subscription, true);
991
+ }, syncWatermarkPersistDelayMs);
992
+ }
841
993
  emitSyncMessage(subscription, message) {
994
+ const outgoing = this.materializeSyncMessage(subscription, message);
842
995
  for (const listener of Array.from(subscription.listeners))
843
- listener(message);
996
+ listener(outgoing);
997
+ }
998
+ materializeSyncMessage(subscription, message) {
999
+ if (message.type !== "sync.snapshot")
1000
+ return message;
1001
+ return {
1002
+ ...message,
1003
+ result: this.overlay.apply(subscription.path, message.result, message.key),
1004
+ };
1005
+ }
1006
+ emitOptimisticCollection(collection) {
1007
+ for (const subscription of this.syncSubscriptions.values()) {
1008
+ if (subscription.path !== collection || subscription.lastMessage?.type !== "sync.snapshot")
1009
+ continue;
1010
+ this.emitSyncMessage(subscription, subscription.lastMessage);
1011
+ }
844
1012
  }
845
1013
  markSyncSubscriptionsOutOfDate() {
846
1014
  for (const subscription of this.syncSubscriptions.values()) {
@@ -905,6 +1073,7 @@ export class GonvexClient {
905
1073
  subscription.cursor = cached.cursor;
906
1074
  subscription.keyField = cached.keyField;
907
1075
  subscription.mode = cached.mode;
1076
+ subscription.truncated = cached.truncated;
908
1077
  subscription.orderBy = cached.orderBy;
909
1078
  subscription.orderDirection = cached.orderDirection;
910
1079
  subscription.maxRows = cached.maxRows;
@@ -915,6 +1084,7 @@ export class GonvexClient {
915
1084
  subscription.hashes = {};
916
1085
  subscription.integrityDigest = undefined;
917
1086
  subscription.integrityRows = undefined;
1087
+ subscription.integrityEpoch = undefined;
918
1088
  const message = {
919
1089
  type: "sync.snapshot",
920
1090
  id: subscription.id,
@@ -958,6 +1128,7 @@ export class GonvexClient {
958
1128
  subscription.hashes = hashes;
959
1129
  subscription.integrityDigest = digest;
960
1130
  subscription.integrityRows = rows;
1131
+ subscription.integrityEpoch = subscription.cursor?.epoch;
961
1132
  subscription.opening = false;
962
1133
  this.sendSyncOpen(subscription);
963
1134
  }).catch(() => {
@@ -1039,7 +1210,11 @@ export class GonvexClient {
1039
1210
  this.send({ type: "sync.close", id: latest.id });
1040
1211
  }, this.syncSubscriptionRetentionMs);
1041
1212
  }
1042
- persistSyncSnapshot(subscription) {
1213
+ persistSyncSnapshot(subscription, fromWatermark = false) {
1214
+ if (!fromWatermark && subscription.watermarkPersistTimer) {
1215
+ clearTimeout(subscription.watermarkPersistTimer);
1216
+ subscription.watermarkPersistTimer = undefined;
1217
+ }
1043
1218
  const directive = this.queryCacheDirective;
1044
1219
  const store = this.syncStore;
1045
1220
  if (!directive || !store || !subscription.cursor)
@@ -1054,6 +1229,7 @@ export class GonvexClient {
1054
1229
  cursor: subscription.cursor,
1055
1230
  keyField: subscription.keyField,
1056
1231
  mode: subscription.mode,
1232
+ truncated: subscription.truncated,
1057
1233
  orderBy: subscription.orderBy,
1058
1234
  orderDirection: subscription.orderDirection,
1059
1235
  maxRows: subscription.maxRows,
@@ -1065,6 +1241,10 @@ export class GonvexClient {
1065
1241
  this.enqueueSyncPersistence(subscription, scope, () => store.replace(scope, subscription.path, subscription.args, value));
1066
1242
  }
1067
1243
  persistSyncDelta(subscription, upserts, deleted) {
1244
+ if (subscription.watermarkPersistTimer) {
1245
+ clearTimeout(subscription.watermarkPersistTimer);
1246
+ subscription.watermarkPersistTimer = undefined;
1247
+ }
1068
1248
  const directive = this.queryCacheDirective;
1069
1249
  const store = this.syncStore;
1070
1250
  if (!directive || !store || !subscription.cursor)
@@ -1074,6 +1254,7 @@ export class GonvexClient {
1074
1254
  cursor: subscription.cursor,
1075
1255
  keyField: subscription.keyField,
1076
1256
  mode: subscription.mode,
1257
+ truncated: subscription.truncated,
1077
1258
  orderBy: subscription.orderBy,
1078
1259
  orderDirection: subscription.orderDirection,
1079
1260
  upserts,
@@ -1087,8 +1268,100 @@ export class GonvexClient {
1087
1268
  subscription.persistedRows = subscription.rows;
1088
1269
  this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
1089
1270
  }
1271
+ async restoreOutbox() {
1272
+ const entries = await this.mutationOutbox.loadAll();
1273
+ if (this.manuallyClosed)
1274
+ return;
1275
+ for (const entry of entries) {
1276
+ this.addOptimisticMutation(entry.idempotencyKey, entry.patches ?? []);
1277
+ }
1278
+ const nextAttemptAt = Math.min(...entries
1279
+ .filter((entry) => entry.state === "pending")
1280
+ .map((entry) => entry.nextAttemptAt));
1281
+ if (Number.isFinite(nextAttemptAt) && nextAttemptAt > Date.now()) {
1282
+ this.scheduleOutboxDrain(nextAttemptAt - Date.now());
1283
+ }
1284
+ }
1285
+ addOptimisticMutation(mutationId, patches) {
1286
+ if (patches.length === 0 || this.optimisticMutationIds.has(mutationId))
1287
+ return;
1288
+ this.optimisticMutationIds.add(mutationId);
1289
+ this.overlay.add(mutationId, patches);
1290
+ }
1291
+ settleOptimisticMutation(mutationId) {
1292
+ this.optimisticMutationIds.delete(mutationId);
1293
+ this.overlay.settle(mutationId);
1294
+ }
1295
+ rejectOptimisticMutation(mutationId) {
1296
+ this.optimisticMutationIds.delete(mutationId);
1297
+ this.overlay.reject(mutationId);
1298
+ }
1299
+ async drainOutbox() {
1300
+ await this.outboxReady;
1301
+ if (this.drainingOutbox
1302
+ || this.manuallyClosed
1303
+ || !this.socket
1304
+ || this.socket.readyState !== WebSocket.OPEN)
1305
+ return;
1306
+ this.drainingOutbox = true;
1307
+ try {
1308
+ while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
1309
+ const entry = await this.mutationOutbox.nextReady(Date.now());
1310
+ if (!entry)
1311
+ return;
1312
+ await this.mutationOutbox.markInflight(entry.id);
1313
+ try {
1314
+ await this.call("mutation", { kind: "mutation", path: entry.path }, entry.args, this.timeouts.mutationTimeoutMs, entry.idempotencyKey);
1315
+ await this.mutationOutbox.ack(entry.id);
1316
+ this.settleOptimisticMutation(entry.idempotencyKey);
1317
+ }
1318
+ catch (error) {
1319
+ if (error instanceof GonvexClientError && error.code === "server") {
1320
+ await this.mutationOutbox.ack(entry.id);
1321
+ this.rejectOptimisticMutation(entry.idempotencyKey);
1322
+ continue;
1323
+ }
1324
+ await this.mutationOutbox.fail(entry.id, mutationErrorMessage(error));
1325
+ this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
1326
+ return;
1327
+ }
1328
+ }
1329
+ }
1330
+ finally {
1331
+ this.drainingOutbox = false;
1332
+ }
1333
+ }
1334
+ scheduleOutboxDrain(delay) {
1335
+ if (this.manuallyClosed)
1336
+ return;
1337
+ if (this.outboxDrainTimer)
1338
+ clearTimeout(this.outboxDrainTimer);
1339
+ this.outboxDrainTimer = setTimeout(() => {
1340
+ this.outboxDrainTimer = undefined;
1341
+ void this.drainOutbox();
1342
+ }, delay);
1343
+ }
1090
1344
  mutation(ref, args = {}, options = {}) {
1091
- return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs);
1345
+ const mutationId = randomID();
1346
+ const patches = options.optimistic ?? [];
1347
+ this.addOptimisticMutation(mutationId, patches);
1348
+ return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId).then((result) => {
1349
+ this.settleOptimisticMutation(mutationId);
1350
+ return result;
1351
+ }).catch(async (error) => {
1352
+ if (isQueueableMutationError(error) && options.offline === "queue") {
1353
+ await this.mutationOutbox.enqueue({
1354
+ path: ref.path,
1355
+ args,
1356
+ idempotencyKey: mutationId,
1357
+ entityKeys: patches.map((patch) => patch.rowId),
1358
+ patches,
1359
+ });
1360
+ return { status: "queued", mutationId };
1361
+ }
1362
+ this.rejectOptimisticMutation(mutationId);
1363
+ throw error;
1364
+ });
1092
1365
  }
1093
1366
  action(ref, args = {}, options = {}) {
1094
1367
  return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
@@ -1206,9 +1479,9 @@ export class GonvexClient {
1206
1479
  this.notifyConnectionState();
1207
1480
  return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
1208
1481
  }
1209
- call(kind, ref, args, timeoutMs) {
1482
+ call(kind, ref, args, timeoutMs, id) {
1210
1483
  this.connect();
1211
- const entry = this.registerCall(kind, ref, args, timeoutMs);
1484
+ const entry = this.registerCall(kind, ref, args, timeoutMs, id);
1212
1485
  if (kind === "mutation") {
1213
1486
  try {
1214
1487
  const w = globalThis;
@@ -1224,8 +1497,8 @@ export class GonvexClient {
1224
1497
  this.notifyConnectionState();
1225
1498
  return entry.promise;
1226
1499
  }
1227
- registerCall(kind, ref, args, timeoutMs) {
1228
- const id = randomID();
1500
+ registerCall(kind, ref, args, timeoutMs, callId = randomID()) {
1501
+ const id = callId;
1229
1502
  const clientSentAtMs = nowMs();
1230
1503
  const promise = new Promise((resolve, reject) => {
1231
1504
  const pending = { id, kind, path: ref.path, reject };
@@ -1525,12 +1798,17 @@ export class GonvexClient {
1525
1798
  this.syncScopeGeneration += 1;
1526
1799
  for (const subscription of this.syncSubscriptions.values()) {
1527
1800
  this.clearSyncRetry(subscription, true);
1801
+ if (subscription.watermarkPersistTimer) {
1802
+ clearTimeout(subscription.watermarkPersistTimer);
1803
+ subscription.watermarkPersistTimer = undefined;
1804
+ }
1528
1805
  subscription.isUpToDate = false;
1529
1806
  subscription.rows = [];
1530
1807
  subscription.persistedRows = undefined;
1531
1808
  subscription.hashes = {};
1532
1809
  subscription.integrityDigest = undefined;
1533
1810
  subscription.integrityRows = undefined;
1811
+ subscription.integrityEpoch = undefined;
1534
1812
  subscription.forceFullIntegrity = false;
1535
1813
  subscription.cursor = undefined;
1536
1814
  subscription.lastMessage = undefined;
@@ -1675,11 +1953,20 @@ export class GonvexClient {
1675
1953
  device: event.device ?? browserTelemetryInfo(),
1676
1954
  });
1677
1955
  }
1678
- sendAuth(force) {
1679
- if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project)
1956
+ sendAuth(force, options = {}) {
1957
+ if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project && !this.auth.fetchToken)
1680
1958
  return;
1681
1959
  this.authInFlight = true;
1960
+ this.authRetriedAfterError = false;
1682
1961
  this.armAuthWatchdog();
1962
+ const fetcher = this.auth.fetchToken;
1963
+ if (fetcher && options.useFetcher !== false) {
1964
+ void this.fetchAndSendAuth(fetcher);
1965
+ return;
1966
+ }
1967
+ this.sendAuthFrame();
1968
+ }
1969
+ sendAuthFrame() {
1683
1970
  this.sendNow({
1684
1971
  type: "auth",
1685
1972
  id: randomID(),
@@ -1687,8 +1974,77 @@ export class GonvexClient {
1687
1974
  project: this.auth.project,
1688
1975
  tenant: this.auth.tenant,
1689
1976
  device: browserTelemetryInfo(),
1977
+ capabilities: { syncReadyMany: 1, syncWatermark: 1 },
1690
1978
  });
1691
1979
  }
1980
+ // Tokens from a fetcher are typically short-lived while the socket (and any
1981
+ // disconnect gap) can span hours: replaying the token that was current at
1982
+ // setAuth time guarantees an auth.error after a long sleep. authInFlight is
1983
+ // already true here, so everything else queues behind the fetch exactly as
1984
+ // it queues behind the server's auth reply.
1985
+ async fetchAndSendAuth(fetcher) {
1986
+ const generation = ++this.authFetchGeneration;
1987
+ const socket = this.socket;
1988
+ let token;
1989
+ try {
1990
+ token = await fetcher({ forceRefreshToken: false });
1991
+ }
1992
+ catch {
1993
+ // A fetcher that cannot reach its identity provider (offline start)
1994
+ // must not sign the session out — fall back to the installed token.
1995
+ token = undefined;
1996
+ }
1997
+ if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
1998
+ return;
1999
+ if (typeof token === "string" && token) {
2000
+ this.applyAuth({ token });
2001
+ }
2002
+ else if (token === null) {
2003
+ // The fetcher is authoritative about sign-out.
2004
+ this.applyAuth({ token: undefined });
2005
+ }
2006
+ // A dead socket's close handler already reset authInFlight; the next
2007
+ // reconnect runs its own sendAuth, so this resolve has nothing to send.
2008
+ if (this.socket !== socket || socket?.readyState !== WebSocket.OPEN)
2009
+ return;
2010
+ this.sendAuthFrame();
2011
+ }
2012
+ async refreshRejectedAuth(fetcher, rejectedToken, error) {
2013
+ const generation = ++this.authFetchGeneration;
2014
+ const socket = this.socket;
2015
+ let token;
2016
+ try {
2017
+ token = await fetcher({ forceRefreshToken: true });
2018
+ }
2019
+ catch {
2020
+ token = undefined;
2021
+ }
2022
+ if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
2023
+ return;
2024
+ if (typeof token === "string" && token && token !== rejectedToken) {
2025
+ this.applyAuth({ token });
2026
+ if (this.socket === socket && socket?.readyState === WebSocket.OPEN) {
2027
+ this.sendAuthFrame();
2028
+ }
2029
+ return;
2030
+ }
2031
+ // No fresher credential exists (fetch failed, signed out, or the refresh
2032
+ // returned the very token the server just refused): surface the rejection
2033
+ // and degrade to the unauthenticated flow exactly like the no-fetcher path.
2034
+ this.authInFlight = false;
2035
+ if (this.authWatchdogTimer) {
2036
+ clearTimeout(this.authWatchdogTimer);
2037
+ this.authWatchdogTimer = undefined;
2038
+ }
2039
+ this.resetQueryCacheScope();
2040
+ this.notifyAuthError(error);
2041
+ this.flushPendingMessages();
2042
+ }
2043
+ notifyAuthError(error) {
2044
+ for (const handler of Array.from(this.authErrorHandlers)) {
2045
+ handler(error);
2046
+ }
2047
+ }
1692
2048
  // A lost auth reply (e.g. the server swapped its app plugin and dropped
1693
2049
  // in-flight responses while the socket stayed up) used to leave
1694
2050
  // authInFlight stuck true forever: every later mutation/subscription
@@ -1761,6 +2117,13 @@ function countPendingCalls(calls, kind) {
1761
2117
  }
1762
2118
  return count;
1763
2119
  }
2120
+ function isQueueableMutationError(error) {
2121
+ return error instanceof GonvexClientError
2122
+ && (error.code === "disconnected" || error.code === "timeout");
2123
+ }
2124
+ function mutationErrorMessage(error) {
2125
+ return error instanceof Error ? error.message : String(error);
2126
+ }
1764
2127
  function stableStringify(value) {
1765
2128
  if (typeof value === "string") {
1766
2129
  return JSON.stringify(value)
@@ -1891,6 +2254,7 @@ function authFromOptions(options) {
1891
2254
  tenant: options.tenant,
1892
2255
  telemetry: options.telemetry,
1893
2256
  identity: options.identity,
2257
+ fetchToken: options.fetchToken,
1894
2258
  };
1895
2259
  }
1896
2260
  function normalizeQuerySubscriptionRetentionMs(value) {