@gonvex/client 0.1.18 → 0.1.20

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,5 +1,5 @@
1
1
  import { createQueryCacheStore, defaultQueryCacheReadTimeoutMs, } from "./query-cache.js";
2
- import { createSyncStore } from "./sync-store.js";
2
+ import { createSyncStore, syncHashesDigest, syncRowsHashes, } from "./sync-store.js";
3
3
  import { GonvexErrorReporter } from "./error-reporter.js";
4
4
  export * from "./cache.js";
5
5
  export * from "./cache-coordinator.js";
@@ -39,6 +39,14 @@ export class GonvexClientError extends Error {
39
39
  export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
40
40
  export const DEFAULT_MUTATION_TIMEOUT_MS = 20_000;
41
41
  export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
42
+ // Small collections can send their row hashes immediately and repair in one
43
+ // round trip. Larger collections resume with one 64-byte digest and only send
44
+ // the hash map when the server proves that something actually differs.
45
+ const compactSyncIntegrityThreshold = 256;
46
+ // Must match the runtime's per-frame sync.openMany admission limit. Keeping
47
+ // this client-side prevents one oversized page from stranding every sync in a
48
+ // batch behind a frame-level rejection.
49
+ const maxSyncBatchOpens = 256;
42
50
  export class GonvexClient {
43
51
  url;
44
52
  socket;
@@ -49,18 +57,26 @@ export class GonvexClient {
49
57
  telemetryHandlers = new Set();
50
58
  pendingMessages = [];
51
59
  pendingSyncOpens = new Set();
60
+ pendingQuerySubscribes = new Set();
61
+ syncPersistence = new Map();
52
62
  syncOpenFlushTimer;
63
+ querySubscribeFlushTimer;
53
64
  serverCapabilities = {};
54
65
  auth = {};
55
66
  authInFlight = false;
67
+ authWatchdogTimer;
56
68
  telemetryEnabled = false;
57
69
  queryCache;
58
70
  queryCacheWaitForScope;
59
71
  queryCacheReadTimeoutMs;
60
72
  querySubscriptionRetentionMs;
73
+ syncSubscriptionRetentionMs;
61
74
  syncStore;
62
75
  queryCacheDirective;
63
76
  queryCacheGeneration = 0;
77
+ // Sync collections live under a visibility-only scope that survives query
78
+ // cache rotations (deploys); their warm reads are guarded separately.
79
+ syncScopeGeneration = 0;
64
80
  queryCacheNegotiatedSocketGeneration;
65
81
  syncIdentityGeneration = 0;
66
82
  sessionScopeHandlers = new Set();
@@ -83,6 +99,7 @@ export class GonvexClient {
83
99
  this.queryCacheWaitForScope = options.queryCache !== undefined && options.queryCache !== false;
84
100
  this.queryCacheReadTimeoutMs = queryCacheReadTimeout(options.queryCache === false ? undefined : options.queryCache?.readTimeoutMs);
85
101
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
102
+ this.syncSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.syncSubscriptionRetentionMs);
86
103
  this.syncStore = createSyncStore(options.sync);
87
104
  this.timeouts = {
88
105
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
@@ -109,6 +126,10 @@ export class GonvexClient {
109
126
  inflightOneShotQueries,
110
127
  };
111
128
  }
129
+ /** Metadata advertised by the runtime in its latest session.ready frame. */
130
+ serverInfo() {
131
+ return { ...this.serverCapabilities };
132
+ }
112
133
  subscribeToConnectionState(handler) {
113
134
  this.connectionStateHandlers.add(handler);
114
135
  return () => {
@@ -176,7 +197,12 @@ export class GonvexClient {
176
197
  if (this.socket !== socket || this.manuallyClosed)
177
198
  return;
178
199
  this.isWebSocketConnected = false;
200
+ this.markSyncSubscriptionsOutOfDate();
179
201
  this.authInFlight = false;
202
+ if (this.authWatchdogTimer) {
203
+ clearTimeout(this.authWatchdogTimer);
204
+ this.authWatchdogTimer = undefined;
205
+ }
180
206
  // A subscription queued for the old socket is superseded by the complete
181
207
  // resubscribe below. Queued mutations/actions are rejected below, so
182
208
  // drop them too — flushing them after reconnect would fire writes whose
@@ -216,6 +242,10 @@ export class GonvexClient {
216
242
  }
217
243
  if (message.type === "auth.result" || message.type === "auth.error") {
218
244
  this.authInFlight = false;
245
+ if (this.authWatchdogTimer) {
246
+ clearTimeout(this.authWatchdogTimer);
247
+ this.authWatchdogTimer = undefined;
248
+ }
219
249
  if (message.type === "auth.result") {
220
250
  this.installQueryCacheDirective(queryCacheDirectiveFromAuthResult(message.result));
221
251
  this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
@@ -251,12 +281,19 @@ export class GonvexClient {
251
281
  this.rejectPendingCalls((call) => new GonvexClientError(`Gonvex client was closed while waiting for ${call.kind} ${call.path}`, { code: "closed", path: call.path, operation: call.kind }));
252
282
  for (const subscription of this.syncSubscriptions.values()) {
253
283
  this.clearSyncRetry(subscription);
284
+ if (subscription.unsubscribeTimer)
285
+ clearTimeout(subscription.unsubscribeTimer);
254
286
  }
255
287
  if (this.syncOpenFlushTimer) {
256
288
  clearTimeout(this.syncOpenFlushTimer);
257
289
  this.syncOpenFlushTimer = undefined;
258
290
  }
259
291
  this.pendingSyncOpens.clear();
292
+ if (this.querySubscribeFlushTimer) {
293
+ clearTimeout(this.querySubscribeFlushTimer);
294
+ this.querySubscribeFlushTimer = undefined;
295
+ }
296
+ this.pendingQuerySubscribes.clear();
260
297
  for (const subscription of this.querySubscriptions.values()) {
261
298
  if (subscription.cacheReadFallbackTimer)
262
299
  clearTimeout(subscription.cacheReadFallbackTimer);
@@ -411,6 +448,13 @@ export class GonvexClient {
411
448
  }
412
449
  normalizeSubscriptionMessage(subscription, message) {
413
450
  if (message.type === "query.progress") {
451
+ if (subscription.lastMessage?.type !== "query.result") {
452
+ // A progress frame only confirms that an advertised cache revision is
453
+ // current. If the in-memory snapshot is gone, accepting it would leave
454
+ // listeners permanently without a value.
455
+ this.requestSubscriptionSnapshot(subscription);
456
+ return undefined;
457
+ }
414
458
  if (!this.acceptRevision(subscription, message.throughRevision))
415
459
  return undefined;
416
460
  subscription.lastRevision = message.throughRevision;
@@ -517,6 +561,10 @@ export class GonvexClient {
517
561
  const key = querySubscriptionKey(ref, args);
518
562
  const existing = this.syncSubscriptions.get(key);
519
563
  if (existing) {
564
+ if (existing.unsubscribeTimer) {
565
+ clearTimeout(existing.unsubscribeTimer);
566
+ existing.unsubscribeTimer = undefined;
567
+ }
520
568
  existing.listeners.add(onMessage);
521
569
  if (existing.lastMessage) {
522
570
  queueMicrotask(() => {
@@ -537,6 +585,10 @@ export class GonvexClient {
537
585
  opening: false,
538
586
  persistence: Promise.resolve(),
539
587
  retryAttempt: 0,
588
+ isUpToDate: false,
589
+ hashes: {},
590
+ forceFullIntegrity: false,
591
+ verificationGeneration: 0,
540
592
  };
541
593
  this.syncSubscriptions.set(key, subscription);
542
594
  this.handlers.set(subscription.id, (message) => this.handleSyncMessage(subscription, message));
@@ -546,7 +598,8 @@ export class GonvexClient {
546
598
  watchSync(ref, args = {}) {
547
599
  let latest;
548
600
  let latestError;
549
- let isUpToDate = false;
601
+ const thisClient = this;
602
+ const key = querySubscriptionKey(ref, args);
550
603
  const updateHandlers = new Set();
551
604
  const notify = () => {
552
605
  for (const handler of updateHandlers)
@@ -559,7 +612,10 @@ export class GonvexClient {
559
612
  notify();
560
613
  }
561
614
  else if (message.type === "sync.ready") {
562
- isUpToDate = true;
615
+ latestError = undefined;
616
+ notify();
617
+ }
618
+ else if (message.type === "sync.syncing" || message.type === "sync.reset") {
563
619
  notify();
564
620
  }
565
621
  else if (message.type === "sync.error") {
@@ -570,7 +626,6 @@ export class GonvexClient {
570
626
  const unsubscribeScope = this.onSessionScopeChange(() => {
571
627
  latest = undefined;
572
628
  latestError = undefined;
573
- isUpToDate = false;
574
629
  notify();
575
630
  });
576
631
  return {
@@ -580,7 +635,10 @@ export class GonvexClient {
580
635
  return latest;
581
636
  },
582
637
  status() {
583
- return { isLoading: latest === undefined, isUpToDate };
638
+ return {
639
+ isLoading: latest === undefined,
640
+ isUpToDate: thisClient.syncSubscriptions.get(key)?.isUpToDate === true,
641
+ };
584
642
  },
585
643
  onUpdate(handler) {
586
644
  updateHandlers.add(handler);
@@ -596,7 +654,18 @@ export class GonvexClient {
596
654
  }
597
655
  handleSyncMessage(subscription, message) {
598
656
  if (message.type === "sync.snapshot") {
657
+ // Snapshots are only valid responses to an outstanding sync.open. Live
658
+ // subscriptions advance through deltas; accepting an unsolicited or
659
+ // delayed snapshot could roll a verified collection back to old rows.
660
+ if (!subscription.opening)
661
+ return;
662
+ if (subscription.cursor
663
+ && message.cursor.epoch === subscription.cursor.epoch
664
+ && message.cursor.revision < subscription.cursor.revision)
665
+ return;
599
666
  this.clearSyncRetry(subscription, true);
667
+ subscription.verificationGeneration += 1;
668
+ subscription.isUpToDate = false;
600
669
  subscription.opening = false;
601
670
  subscription.cursor = message.cursor;
602
671
  subscription.keyField = message.key;
@@ -606,6 +675,9 @@ export class GonvexClient {
606
675
  subscription.maxRows = message.maxRows;
607
676
  subscription.maxBytes = message.maxBytes;
608
677
  subscription.rows = boundSyncRows(message.result, message.key, message.maxRows, message.maxBytes, message.orderBy, message.orderDirection);
678
+ subscription.hashes = { ...(message.hashes ?? {}) };
679
+ subscription.integrityDigest = undefined;
680
+ subscription.integrityRows = undefined;
609
681
  const snapshot = { ...message, result: subscription.rows };
610
682
  subscription.lastMessage = snapshot;
611
683
  this.emitSyncMessage(subscription, snapshot);
@@ -614,11 +686,20 @@ export class GonvexClient {
614
686
  }
615
687
  if (message.type === "sync.delta") {
616
688
  if (subscription.cursor && (message.cursor.epoch !== subscription.cursor.epoch
617
- || message.cursor.revision <= subscription.cursor.revision))
689
+ || message.cursor.revision < subscription.cursor.revision
690
+ || (message.cursor.revision === subscription.cursor.revision
691
+ && !message.digest)))
618
692
  return;
619
693
  this.clearSyncRetry(subscription, true);
694
+ subscription.verificationGeneration += 1;
695
+ subscription.isUpToDate = false;
620
696
  subscription.cursor = message.cursor;
621
697
  subscription.rows = applySyncDelta(subscription.rows, subscription.keyField, message.upserts ?? [], message.deleted ?? [], subscription.maxRows, subscription.maxBytes, subscription.orderBy, subscription.orderDirection);
698
+ for (const key of message.deleted ?? [])
699
+ delete subscription.hashes[key];
700
+ Object.assign(subscription.hashes, message.hashes ?? {});
701
+ subscription.integrityDigest = undefined;
702
+ subscription.integrityRows = undefined;
622
703
  const snapshot = {
623
704
  type: "sync.snapshot",
624
705
  id: subscription.id,
@@ -639,35 +720,128 @@ export class GonvexClient {
639
720
  }
640
721
  if (message.type === "sync.reset") {
641
722
  this.clearSyncRetry(subscription, true);
723
+ subscription.verificationGeneration += 1;
724
+ subscription.isUpToDate = false;
642
725
  subscription.cursor = undefined;
643
726
  subscription.rows = [];
727
+ subscription.hashes = {};
728
+ subscription.integrityDigest = undefined;
729
+ subscription.integrityRows = undefined;
730
+ subscription.forceFullIntegrity = false;
644
731
  subscription.lastMessage = undefined;
645
732
  subscription.opening = false;
646
733
  const directive = this.queryCacheDirective;
647
734
  const store = this.syncStore;
648
735
  if (directive && store) {
649
- this.enqueueSyncPersistence(subscription, () => store.delete(directive.scope, subscription.path, subscription.args));
736
+ const scope = syncPersistenceScope(directive);
737
+ this.enqueueSyncPersistence(subscription, scope, () => store.delete(scope, subscription.path, subscription.args));
650
738
  }
739
+ this.emitSyncMessage(subscription, message);
651
740
  queueMicrotask(() => this.sendSyncOpen(subscription));
652
741
  return;
653
742
  }
654
- if (message.type === "sync.ready") {
655
- this.clearSyncRetry(subscription, true);
743
+ if (message.type === "sync.syncing") {
744
+ subscription.verificationGeneration += 1;
745
+ subscription.isUpToDate = false;
746
+ this.emitSyncMessage(subscription, message);
747
+ return;
748
+ }
749
+ if (message.type === "sync.needHashes") {
750
+ subscription.verificationGeneration += 1;
751
+ subscription.isUpToDate = false;
656
752
  subscription.opening = false;
657
- subscription.cursor = message.cursor;
658
- subscription.mode = message.mode ?? subscription.mode;
659
- this.persistSyncSnapshot(subscription);
753
+ subscription.forceFullIntegrity = true;
754
+ this.emitSyncMessage(subscription, {
755
+ type: "sync.syncing",
756
+ id: subscription.id,
757
+ path: subscription.path,
758
+ reason: "integrity-reconciling",
759
+ });
760
+ queueMicrotask(() => this.sendSyncOpen(subscription));
761
+ return;
762
+ }
763
+ if (message.type === "sync.ready") {
764
+ if (!subscription.cursor || (message.cursor.epoch !== subscription.cursor.epoch
765
+ || message.cursor.revision < subscription.cursor.revision))
766
+ return;
767
+ const generation = ++subscription.verificationGeneration;
768
+ if (!message.digest && this.serverCapabilities.syncIntegrity === 1) {
769
+ this.handleSyncMessage(subscription, {
770
+ type: "sync.reset",
771
+ id: subscription.id,
772
+ path: subscription.path,
773
+ reason: "integrity-missing",
774
+ });
775
+ return;
776
+ }
777
+ void syncRowsHashes(subscription.rows, subscription.keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ digest, hashes })))).then(({ digest, hashes }) => {
778
+ if (generation !== subscription.verificationGeneration
779
+ || this.syncSubscriptions.get(subscription.key) !== subscription)
780
+ return;
781
+ if (message.digest && digest !== message.digest) {
782
+ this.handleSyncMessage(subscription, {
783
+ type: "sync.reset",
784
+ id: subscription.id,
785
+ path: subscription.path,
786
+ reason: "integrity-mismatch",
787
+ });
788
+ return;
789
+ }
790
+ subscription.hashes = hashes;
791
+ subscription.integrityDigest = digest;
792
+ subscription.integrityRows = subscription.rows;
793
+ this.acceptSyncReady(subscription, message, digest);
794
+ }).catch(() => {
795
+ if (generation !== subscription.verificationGeneration)
796
+ return;
797
+ this.handleSyncMessage(subscription, {
798
+ type: "sync.reset",
799
+ id: subscription.id,
800
+ path: subscription.path,
801
+ reason: "integrity-mismatch",
802
+ });
803
+ });
804
+ return;
660
805
  }
661
806
  if (message.type === "sync.error") {
807
+ subscription.verificationGeneration += 1;
808
+ subscription.isUpToDate = false;
662
809
  subscription.opening = false;
663
810
  this.scheduleSyncRetry(subscription);
664
811
  }
665
812
  this.emitSyncMessage(subscription, message);
666
813
  }
814
+ acceptSyncReady(subscription, message, verifiedDigest = message.digest) {
815
+ this.clearSyncRetry(subscription, true);
816
+ subscription.isUpToDate = true;
817
+ subscription.opening = false;
818
+ subscription.cursor = message.cursor;
819
+ subscription.mode = message.mode ?? subscription.mode;
820
+ subscription.integrityDigest = verifiedDigest;
821
+ subscription.integrityRows = subscription.rows;
822
+ subscription.forceFullIntegrity = false;
823
+ this.persistSyncSnapshot(subscription);
824
+ this.emitSyncMessage(subscription, message);
825
+ }
667
826
  emitSyncMessage(subscription, message) {
668
827
  for (const listener of Array.from(subscription.listeners))
669
828
  listener(message);
670
829
  }
830
+ markSyncSubscriptionsOutOfDate() {
831
+ for (const subscription of this.syncSubscriptions.values()) {
832
+ const wasUpToDate = subscription.isUpToDate;
833
+ subscription.verificationGeneration += 1;
834
+ subscription.isUpToDate = false;
835
+ if (!wasUpToDate)
836
+ continue;
837
+ this.emitSyncMessage(subscription, {
838
+ type: "sync.syncing",
839
+ id: subscription.id,
840
+ path: subscription.path,
841
+ reason: "disconnected",
842
+ });
843
+ }
844
+ }
671
845
  startSync(subscription) {
672
846
  const directive = this.queryCacheDirective;
673
847
  const store = this.syncStore;
@@ -677,16 +851,20 @@ export class GonvexClient {
677
851
  this.sendSyncOpen(subscription);
678
852
  return;
679
853
  }
680
- const generation = this.queryCacheGeneration;
854
+ const scope = syncPersistenceScope(directive);
855
+ const generation = this.syncScopeGeneration;
681
856
  if (subscription.cacheReadGeneration === generation)
682
857
  return;
683
858
  subscription.cacheReadGeneration = generation;
684
- void store.load(directive.scope, subscription.path, subscription.args).then((cached) => {
859
+ void store.load(scope, subscription.path, subscription.args).then((cached) => {
860
+ const currentDirective = this.queryCacheDirective;
685
861
  if (this.syncSubscriptions.get(subscription.key) !== subscription
686
- || this.queryCacheGeneration !== generation
687
- || this.queryCacheDirective?.scope !== directive.scope)
862
+ || this.syncScopeGeneration !== generation
863
+ || !currentDirective
864
+ || syncPersistenceScope(currentDirective) !== scope)
688
865
  return;
689
866
  if (cached) {
867
+ subscription.isUpToDate = false;
690
868
  subscription.rows = cached.rows;
691
869
  subscription.cursor = cached.cursor;
692
870
  subscription.keyField = cached.keyField;
@@ -695,6 +873,12 @@ export class GonvexClient {
695
873
  subscription.orderDirection = cached.orderDirection;
696
874
  subscription.maxRows = cached.maxRows;
697
875
  subscription.maxBytes = cached.maxBytes;
876
+ // Stored hash metadata is never trusted. sendSyncOpen hashes these
877
+ // actual materialized rows before advertising a cursor, which allows a
878
+ // corrupt row to be repaired by delta without a full cache reset.
879
+ subscription.hashes = {};
880
+ subscription.integrityDigest = undefined;
881
+ subscription.integrityRows = undefined;
698
882
  const message = {
699
883
  type: "sync.snapshot",
700
884
  id: subscription.id,
@@ -717,6 +901,38 @@ export class GonvexClient {
717
901
  sendSyncOpen(subscription) {
718
902
  if (subscription.listeners.size === 0 || subscription.opening)
719
903
  return;
904
+ if (subscription.cursor && subscription.integrityRows !== subscription.rows) {
905
+ subscription.opening = true;
906
+ const rows = subscription.rows;
907
+ const keyField = subscription.keyField;
908
+ const socketGeneration = this.socketGeneration;
909
+ void syncRowsHashes(rows, keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ hashes, digest })))).then(({ hashes, digest }) => {
910
+ if (this.socketGeneration !== socketGeneration
911
+ || this.syncSubscriptions.get(subscription.key) !== subscription
912
+ || subscription.listeners.size === 0
913
+ || subscription.rows !== rows
914
+ || subscription.keyField !== keyField)
915
+ return;
916
+ subscription.hashes = hashes;
917
+ subscription.integrityDigest = digest;
918
+ subscription.integrityRows = rows;
919
+ subscription.opening = false;
920
+ this.sendSyncOpen(subscription);
921
+ }).catch(() => {
922
+ if (this.socketGeneration !== socketGeneration
923
+ || this.syncSubscriptions.get(subscription.key) !== subscription
924
+ || subscription.rows !== rows)
925
+ return;
926
+ subscription.opening = false;
927
+ this.handleSyncMessage(subscription, {
928
+ type: "sync.reset",
929
+ id: subscription.id,
930
+ path: subscription.path,
931
+ reason: "integrity-mismatch",
932
+ });
933
+ });
934
+ return;
935
+ }
720
936
  subscription.opening = true;
721
937
  subscription.socketGeneration = this.socketGeneration;
722
938
  const open = this.syncOpenRequest(subscription);
@@ -730,15 +946,23 @@ export class GonvexClient {
730
946
  this.send({ type: "sync.open", ...open });
731
947
  }
732
948
  syncOpenRequest(subscription) {
733
- const keys = subscription.mode === "eager"
734
- ? undefined
735
- : subscription.rows.map((row) => syncRowKey(row, subscription.keyField)).filter(Boolean);
949
+ const fullIntegrity = subscription.cursor !== undefined && (subscription.forceFullIntegrity
950
+ || !subscription.integrityDigest
951
+ || subscription.rows.length <= compactSyncIntegrityThreshold);
952
+ const keys = fullIntegrity
953
+ ? subscription.rows.map((row) => syncRowKey(row, subscription.keyField)).filter(Boolean)
954
+ : undefined;
736
955
  return {
737
956
  id: subscription.id,
738
957
  path: subscription.path,
739
958
  args: subscription.args,
740
959
  cursor: subscription.cursor,
741
960
  keys,
961
+ hashes: fullIntegrity && Object.keys(subscription.hashes).length > 0
962
+ ? subscription.hashes
963
+ : undefined,
964
+ digest: subscription.cursor ? subscription.integrityDigest : undefined,
965
+ fullIntegrity: fullIntegrity || undefined,
742
966
  };
743
967
  }
744
968
  flushSyncOpens() {
@@ -750,28 +974,35 @@ export class GonvexClient {
750
974
  && subscription.listeners.size > 0
751
975
  && this.syncSubscriptions.get(subscription.key) === subscription))
752
976
  .map((subscription) => this.syncOpenRequest(subscription));
753
- if (opens.length === 0)
754
- return;
755
- this.send({ type: "sync.openMany", opens });
977
+ for (let offset = 0; offset < opens.length; offset += maxSyncBatchOpens) {
978
+ this.send({ type: "sync.openMany", opens: opens.slice(offset, offset + maxSyncBatchOpens) });
979
+ }
756
980
  }
757
981
  unsubscribeSyncListener(key, listener) {
758
982
  const subscription = this.syncSubscriptions.get(key);
759
983
  if (!subscription)
760
984
  return;
761
985
  subscription.listeners.delete(listener);
762
- if (subscription.listeners.size > 0)
986
+ if (subscription.listeners.size > 0 || subscription.unsubscribeTimer)
763
987
  return;
764
- this.clearSyncRetry(subscription);
765
- this.pendingSyncOpens.delete(subscription);
766
- this.syncSubscriptions.delete(key);
767
- this.handlers.delete(subscription.id);
768
- this.send({ type: "sync.close", id: subscription.id });
988
+ subscription.unsubscribeTimer = setTimeout(() => {
989
+ const latest = this.syncSubscriptions.get(key);
990
+ if (!latest || latest.listeners.size > 0)
991
+ return;
992
+ latest.unsubscribeTimer = undefined;
993
+ this.clearSyncRetry(latest);
994
+ this.pendingSyncOpens.delete(latest);
995
+ this.syncSubscriptions.delete(key);
996
+ this.handlers.delete(latest.id);
997
+ this.send({ type: "sync.close", id: latest.id });
998
+ }, this.syncSubscriptionRetentionMs);
769
999
  }
770
1000
  persistSyncSnapshot(subscription) {
771
1001
  const directive = this.queryCacheDirective;
772
1002
  const store = this.syncStore;
773
1003
  if (!directive || !store || !subscription.cursor)
774
1004
  return;
1005
+ const scope = syncPersistenceScope(directive);
775
1006
  const value = {
776
1007
  rows: subscription.rows,
777
1008
  cursor: subscription.cursor,
@@ -781,14 +1012,16 @@ export class GonvexClient {
781
1012
  orderDirection: subscription.orderDirection,
782
1013
  maxRows: subscription.maxRows,
783
1014
  maxBytes: subscription.maxBytes,
1015
+ hashes: { ...subscription.hashes },
784
1016
  };
785
- this.enqueueSyncPersistence(subscription, () => store.replace(directive.scope, subscription.path, subscription.args, value));
1017
+ this.enqueueSyncPersistence(subscription, scope, () => store.replace(scope, subscription.path, subscription.args, value));
786
1018
  }
787
1019
  persistSyncDelta(subscription, upserts, deleted) {
788
1020
  const directive = this.queryCacheDirective;
789
1021
  const store = this.syncStore;
790
1022
  if (!directive || !store || !subscription.cursor)
791
1023
  return;
1024
+ const scope = syncPersistenceScope(directive);
792
1025
  const value = {
793
1026
  cursor: subscription.cursor,
794
1027
  keyField: subscription.keyField,
@@ -799,8 +1032,9 @@ export class GonvexClient {
799
1032
  deleted,
800
1033
  maxRows: subscription.maxRows,
801
1034
  maxBytes: subscription.maxBytes,
1035
+ hashes: { ...subscription.hashes },
802
1036
  };
803
- this.enqueueSyncPersistence(subscription, () => store.applyDelta(directive.scope, subscription.path, subscription.args, value));
1037
+ this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
804
1038
  }
805
1039
  mutation(ref, args = {}, options = {}) {
806
1040
  return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs);
@@ -876,11 +1110,73 @@ export class GonvexClient {
876
1110
  this.connect();
877
1111
  this.sendSubscription(subscription);
878
1112
  }
1113
+ /**
1114
+ * Flush a queue of mutations in one `mutation.callMany` frame (queue order,
1115
+ * one websocket round trip). Each entry settles independently — a failed
1116
+ * call does not reject the batch — so offline queues can apply per-row
1117
+ * outcomes. Falls back to sequential `mutation` calls on runtimes that do
1118
+ * not advertise the `mutationBatch` capability.
1119
+ */
1120
+ async mutationMany(calls, options = {}) {
1121
+ if (calls.length === 0)
1122
+ return [];
1123
+ this.connect();
1124
+ const timeoutMs = options.timeoutMs ?? this.timeouts.mutationTimeoutMs;
1125
+ const settle = (promise, path) => promise
1126
+ .then((result) => ({ status: "ok", result }))
1127
+ .catch((error) => ({
1128
+ status: "error",
1129
+ error: error instanceof GonvexClientError
1130
+ ? error
1131
+ : new GonvexClientError(String(error), { code: "server", path, operation: "mutation" }),
1132
+ }));
1133
+ if (this.serverCapabilities.mutationBatch !== 1) {
1134
+ const outcomes = [];
1135
+ for (const call of calls) {
1136
+ outcomes.push(await settle(this.mutation(call.ref, call.args ?? {}, options), call.ref.path));
1137
+ }
1138
+ return outcomes;
1139
+ }
1140
+ const registered = calls.map((call) => {
1141
+ const entry = this.registerCall("mutation", call.ref, call.args ?? {}, timeoutMs);
1142
+ return { ...entry, path: call.ref.path, args: call.args ?? {} };
1143
+ });
1144
+ for (let offset = 0; offset < registered.length; offset += maxSyncBatchOpens) {
1145
+ this.send({
1146
+ type: "mutation.callMany",
1147
+ calls: registered.slice(offset, offset + maxSyncBatchOpens).map((entry) => ({
1148
+ id: entry.id,
1149
+ path: entry.path,
1150
+ args: entry.args,
1151
+ trace: { clientSentAtMs: entry.clientSentAtMs },
1152
+ })),
1153
+ });
1154
+ }
1155
+ this.notifyConnectionState();
1156
+ return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
1157
+ }
879
1158
  call(kind, ref, args, timeoutMs) {
880
1159
  this.connect();
1160
+ const entry = this.registerCall(kind, ref, args, timeoutMs);
1161
+ if (kind === "mutation") {
1162
+ try {
1163
+ const w = globalThis;
1164
+ if (w && w.__wsTapLog)
1165
+ w.__wsTapLog.push({ dir: "mut-args", type: "mutation.call", path: ref.path, argTenant: (args && args.tenantId) || null, authTenant: this.auth?.tenant || null, authProject: this.auth?.project || null, href: (w.location && w.location.href) || null });
1166
+ }
1167
+ catch (e) { }
1168
+ this.send({ type: "mutation.call", id: entry.id, path: ref.path, args, trace: { clientSentAtMs: entry.clientSentAtMs } });
1169
+ }
1170
+ else {
1171
+ this.send({ type: "action.call", id: entry.id, path: ref.path, args, trace: { clientSentAtMs: entry.clientSentAtMs } });
1172
+ }
1173
+ this.notifyConnectionState();
1174
+ return entry.promise;
1175
+ }
1176
+ registerCall(kind, ref, args, timeoutMs) {
881
1177
  const id = randomID();
882
1178
  const clientSentAtMs = nowMs();
883
- return new Promise((resolve, reject) => {
1179
+ const promise = new Promise((resolve, reject) => {
884
1180
  const pending = { id, kind, path: ref.path, reject };
885
1181
  const settle = () => {
886
1182
  if (pending.timeoutTimer)
@@ -918,14 +1214,8 @@ export class GonvexClient {
918
1214
  reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
919
1215
  }
920
1216
  });
921
- if (kind === "mutation") {
922
- this.send({ type: "mutation.call", id, path: ref.path, args, trace: { clientSentAtMs } });
923
- }
924
- else {
925
- this.send({ type: "action.call", id, path: ref.path, args, trace: { clientSentAtMs } });
926
- }
927
- this.notifyConnectionState();
928
1217
  });
1218
+ return { id, clientSentAtMs, promise };
929
1219
  }
930
1220
  unsubscribeQueryListener(key, listener) {
931
1221
  const subscription = this.querySubscriptions.get(key);
@@ -966,6 +1256,15 @@ export class GonvexClient {
966
1256
  }
967
1257
  }
968
1258
  subscription.socketGeneration = this.socketGeneration;
1259
+ // Route reloads register dozens of live queries at once. Collapse the
1260
+ // burst into one batched frame per tick instead of one frame per query.
1261
+ if (this.serverCapabilities.queryBatch === 1) {
1262
+ this.pendingQuerySubscribes.add(subscription);
1263
+ if (!this.querySubscribeFlushTimer) {
1264
+ this.querySubscribeFlushTimer = setTimeout(() => this.flushQuerySubscribes(), 0);
1265
+ }
1266
+ return;
1267
+ }
969
1268
  this.send({
970
1269
  type: "query.subscribe",
971
1270
  id: subscription.id,
@@ -974,6 +1273,24 @@ export class GonvexClient {
974
1273
  cacheRevision: subscription.cachedRevision,
975
1274
  });
976
1275
  }
1276
+ flushQuerySubscribes() {
1277
+ this.querySubscribeFlushTimer = undefined;
1278
+ const subscriptions = Array.from(this.pendingQuerySubscribes);
1279
+ this.pendingQuerySubscribes.clear();
1280
+ const subscribes = subscriptions
1281
+ .filter((subscription) => (subscription.listeners.size > 0
1282
+ && subscription.socketGeneration === this.socketGeneration
1283
+ && this.querySubscriptions.get(subscription.key) === subscription))
1284
+ .map((subscription) => ({
1285
+ id: subscription.id,
1286
+ path: subscription.path,
1287
+ args: subscription.args,
1288
+ cacheRevision: subscription.cachedRevision,
1289
+ }));
1290
+ for (let offset = 0; offset < subscribes.length; offset += maxSyncBatchOpens) {
1291
+ this.send({ type: "query.subscribeMany", subscribes: subscribes.slice(offset, offset + maxSyncBatchOpens) });
1292
+ }
1293
+ }
977
1294
  resumeQuerySubscriptions() {
978
1295
  for (const subscription of this.querySubscriptions.values()) {
979
1296
  if (subscription.listeners.size === 0)
@@ -981,11 +1298,19 @@ export class GonvexClient {
981
1298
  this.sendSubscription(subscription);
982
1299
  }
983
1300
  }
984
- enqueueSyncPersistence(subscription, operation) {
985
- subscription.persistence = subscription.persistence
1301
+ enqueueSyncPersistence(subscription, scope, operation) {
1302
+ const key = `${scope}\u0000${subscription.key}`;
1303
+ const previous = this.syncPersistence.get(key) ?? Promise.resolve();
1304
+ const pending = previous
986
1305
  .catch(() => undefined)
987
1306
  .then(operation)
988
1307
  .catch(() => undefined);
1308
+ this.syncPersistence.set(key, pending);
1309
+ subscription.persistence = pending;
1310
+ void pending.finally(() => {
1311
+ if (this.syncPersistence.get(key) === pending)
1312
+ this.syncPersistence.delete(key);
1313
+ });
989
1314
  }
990
1315
  scheduleSyncRetry(subscription) {
991
1316
  if (this.manuallyClosed
@@ -1015,6 +1340,10 @@ export class GonvexClient {
1015
1340
  subscription.retryAttempt = 0;
1016
1341
  }
1017
1342
  requestSubscriptionSnapshot(subscription) {
1343
+ // Do not advertise the cache revision while recovering. Otherwise the
1344
+ // runtime can answer with another progress frame instead of a snapshot.
1345
+ subscription.cachedRevision = undefined;
1346
+ subscription.serverSettled = false;
1018
1347
  subscription.socketGeneration = undefined;
1019
1348
  this.sendSubscription(subscription);
1020
1349
  }
@@ -1064,12 +1393,21 @@ export class GonvexClient {
1064
1393
  this.resetQueryCacheScope();
1065
1394
  return;
1066
1395
  }
1067
- if (this.queryCacheDirective?.scope === value.scope) {
1396
+ const previous = this.queryCacheDirective;
1397
+ const syncScopeChanged = previous !== undefined
1398
+ && syncPersistenceScope(previous) !== syncPersistenceScope(value);
1399
+ if (previous?.scope === value.scope && !syncScopeChanged) {
1068
1400
  this.queryCacheDirective = value;
1069
1401
  return;
1070
1402
  }
1071
- if (this.queryCacheDirective) {
1072
- this.resetQueryCacheScope();
1403
+ if (previous) {
1404
+ // A deploy rotates the query-result scope (results depend on code), but
1405
+ // sync collections are keyed by visibility and survive it: their rows,
1406
+ // cursors, and in-flight warm reads stay valid and are verified by the
1407
+ // server's reconcile on the next open.
1408
+ this.resetQueryResultCacheState();
1409
+ if (syncScopeChanged)
1410
+ this.resetSyncCacheState();
1073
1411
  }
1074
1412
  this.queryCacheDirective = value;
1075
1413
  const identity = authIdentityKey(this.auth);
@@ -1099,8 +1437,16 @@ export class GonvexClient {
1099
1437
  }
1100
1438
  resetQueryCacheScope() {
1101
1439
  const hadScope = this.queryCacheDirective !== undefined;
1102
- this.queryCacheGeneration += 1;
1103
1440
  this.queryCacheDirective = undefined;
1441
+ this.resetQueryResultCacheState();
1442
+ this.resetSyncCacheState();
1443
+ if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
1444
+ for (const handler of this.sessionScopeHandlers)
1445
+ handler();
1446
+ }
1447
+ }
1448
+ resetQueryResultCacheState() {
1449
+ this.queryCacheGeneration += 1;
1104
1450
  this.queryCacheNegotiatedSocketGeneration = undefined;
1105
1451
  for (const subscription of this.querySubscriptions.values()) {
1106
1452
  subscription.lastMessage = undefined;
@@ -1112,17 +1458,22 @@ export class GonvexClient {
1112
1458
  subscription.cacheReadFallbackTimer = undefined;
1113
1459
  subscription.cachedRevision = undefined;
1114
1460
  }
1461
+ }
1462
+ resetSyncCacheState() {
1463
+ this.syncScopeGeneration += 1;
1115
1464
  for (const subscription of this.syncSubscriptions.values()) {
1116
1465
  this.clearSyncRetry(subscription, true);
1466
+ subscription.isUpToDate = false;
1117
1467
  subscription.rows = [];
1468
+ subscription.hashes = {};
1469
+ subscription.integrityDigest = undefined;
1470
+ subscription.integrityRows = undefined;
1471
+ subscription.forceFullIntegrity = false;
1118
1472
  subscription.cursor = undefined;
1119
1473
  subscription.lastMessage = undefined;
1120
1474
  subscription.cacheReadGeneration = undefined;
1121
1475
  subscription.opening = false;
1122
- }
1123
- if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
1124
- for (const handler of this.sessionScopeHandlers)
1125
- handler();
1476
+ subscription.verificationGeneration += 1;
1126
1477
  }
1127
1478
  }
1128
1479
  startQueryCacheRead(subscription) {
@@ -1265,7 +1616,35 @@ export class GonvexClient {
1265
1616
  if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project)
1266
1617
  return;
1267
1618
  this.authInFlight = true;
1268
- this.sendNow({ type: "auth", id: randomID(), token: this.auth.token, project: this.auth.project, tenant: this.auth.tenant });
1619
+ this.armAuthWatchdog();
1620
+ this.sendNow({
1621
+ type: "auth",
1622
+ id: randomID(),
1623
+ token: this.auth.token,
1624
+ project: this.auth.project,
1625
+ tenant: this.auth.tenant,
1626
+ device: browserTelemetryInfo(),
1627
+ });
1628
+ }
1629
+ // A lost auth reply (e.g. the server swapped its app plugin and dropped
1630
+ // in-flight responses while the socket stayed up) used to leave
1631
+ // authInFlight stuck true forever: every later mutation/subscription
1632
+ // queued into pendingMessages and was never sent — no error, no timeout,
1633
+ // and the server never saw the call. Re-issue auth if no reply arrives.
1634
+ armAuthWatchdog() {
1635
+ if (this.authWatchdogTimer)
1636
+ clearTimeout(this.authWatchdogTimer);
1637
+ this.authWatchdogTimer = setTimeout(() => {
1638
+ this.authWatchdogTimer = undefined;
1639
+ if (!this.authInFlight)
1640
+ return;
1641
+ if (this.socket?.readyState === WebSocket.OPEN) {
1642
+ this.sendAuth(true);
1643
+ }
1644
+ else {
1645
+ this.connect();
1646
+ }
1647
+ }, 10_000);
1269
1648
  }
1270
1649
  send(message) {
1271
1650
  if (this.authInFlight && message.type !== "auth" && message.type !== "telemetry.event") {
@@ -1276,8 +1655,21 @@ export class GonvexClient {
1276
1655
  }
1277
1656
  sendNow(message) {
1278
1657
  const socket = this.socket;
1279
- if (!socket || socket.readyState !== WebSocket.OPEN) {
1280
- socket?.addEventListener("open", () => {
1658
+ if (!socket || socket.readyState === WebSocket.CLOSING || socket.readyState === WebSocket.CLOSED) {
1659
+ // Never drop silently. A missing socket swallowed the message outright,
1660
+ // and an "open" listener on a closing/closed socket never fires — either
1661
+ // way the caller hung forever with the server never seeing the call.
1662
+ // Queue it (auth excepted: reconnect sends a fresh auth itself) and
1663
+ // reconnect; pendingMessages flush once auth settles, and the close
1664
+ // handler rejects pending calls so failures stay loud.
1665
+ if (message.type !== "auth") {
1666
+ this.pendingMessages.push(message);
1667
+ }
1668
+ this.connect();
1669
+ return;
1670
+ }
1671
+ if (socket.readyState === WebSocket.CONNECTING) {
1672
+ socket.addEventListener("open", () => {
1281
1673
  if (message.type === "auth") {
1282
1674
  socket.send(JSON.stringify(message));
1283
1675
  return;
@@ -1307,16 +1699,31 @@ function countPendingCalls(calls, kind) {
1307
1699
  return count;
1308
1700
  }
1309
1701
  function stableStringify(value) {
1702
+ if (typeof value === "string") {
1703
+ return JSON.stringify(value)
1704
+ .replace(/\u2028/g, "\\u2028")
1705
+ .replace(/\u2029/g, "\\u2029");
1706
+ }
1310
1707
  if (value === null || typeof value !== "object")
1311
1708
  return JSON.stringify(value);
1312
1709
  if (Array.isArray(value))
1313
1710
  return `[${value.map(stableStringify).join(",")}]`;
1314
1711
  const record = value;
1315
1712
  return `{${Object.keys(record)
1316
- .sort()
1317
- .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
1713
+ .sort(utf8KeyCompare)
1714
+ .map((key) => `${stableStringify(key)}:${stableStringify(record[key])}`)
1318
1715
  .join(",")}}`;
1319
1716
  }
1717
+ function utf8KeyCompare(left, right) {
1718
+ const leftBytes = new TextEncoder().encode(left);
1719
+ const rightBytes = new TextEncoder().encode(right);
1720
+ const length = Math.min(leftBytes.length, rightBytes.length);
1721
+ for (let index = 0; index < length; index += 1) {
1722
+ if (leftBytes[index] !== rightBytes[index])
1723
+ return leftBytes[index] - rightBytes[index];
1724
+ }
1725
+ return leftBytes.length - rightBytes.length;
1726
+ }
1320
1727
  function sameRevision(left, right) {
1321
1728
  return !!right && left.epoch === right.epoch && left.sequence === right.sequence;
1322
1729
  }
@@ -1377,7 +1784,7 @@ function syncRowKey(value, keyField) {
1377
1784
  return key === null || key === undefined ? "" : String(key);
1378
1785
  }
1379
1786
  function syncJSONSize(value) {
1380
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
1787
+ return new TextEncoder().encode(stableStringify(value)).byteLength;
1381
1788
  }
1382
1789
  function applyKeyedPatch(previous, patch) {
1383
1790
  const rows = new Map();
@@ -1468,12 +1875,25 @@ function validQueryCacheDirective(value) {
1468
1875
  return value.protocolVersion === 1
1469
1876
  && typeof value.scope === "string"
1470
1877
  && value.scope.length >= 16
1878
+ && (value.syncScope === undefined
1879
+ || (typeof value.syncScope === "string" && value.syncScope.length >= 16))
1471
1880
  && typeof value.epoch === "string"
1472
1881
  && value.epoch.length >= 16
1473
1882
  && typeof value.maxAgeMs === "number"
1474
1883
  && Number.isFinite(value.maxAgeMs)
1475
1884
  && value.maxAgeMs > 0;
1476
1885
  }
1886
+ /**
1887
+ * The scope under which sync collections are persisted and resumed. Newer
1888
+ * runtimes send a visibility-only `syncScope` that survives deploys (the
1889
+ * authoritative reconcile on resume guarantees correctness across code
1890
+ * changes); older runtimes only send the bundle-epoch `scope`.
1891
+ */
1892
+ function syncPersistenceScope(directive) {
1893
+ return typeof directive.syncScope === "string" && directive.syncScope.length >= 16
1894
+ ? directive.syncScope
1895
+ : directive.scope;
1896
+ }
1477
1897
  function isJsonRecord(value) {
1478
1898
  return value !== null && typeof value === "object" && !Array.isArray(value);
1479
1899
  }