@gonvex/client 0.1.19 → 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,7 +57,10 @@ 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;
@@ -59,9 +70,13 @@ export class GonvexClient {
59
70
  queryCacheWaitForScope;
60
71
  queryCacheReadTimeoutMs;
61
72
  querySubscriptionRetentionMs;
73
+ syncSubscriptionRetentionMs;
62
74
  syncStore;
63
75
  queryCacheDirective;
64
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;
65
80
  queryCacheNegotiatedSocketGeneration;
66
81
  syncIdentityGeneration = 0;
67
82
  sessionScopeHandlers = new Set();
@@ -84,6 +99,7 @@ export class GonvexClient {
84
99
  this.queryCacheWaitForScope = options.queryCache !== undefined && options.queryCache !== false;
85
100
  this.queryCacheReadTimeoutMs = queryCacheReadTimeout(options.queryCache === false ? undefined : options.queryCache?.readTimeoutMs);
86
101
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
102
+ this.syncSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.syncSubscriptionRetentionMs);
87
103
  this.syncStore = createSyncStore(options.sync);
88
104
  this.timeouts = {
89
105
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
@@ -110,6 +126,10 @@ export class GonvexClient {
110
126
  inflightOneShotQueries,
111
127
  };
112
128
  }
129
+ /** Metadata advertised by the runtime in its latest session.ready frame. */
130
+ serverInfo() {
131
+ return { ...this.serverCapabilities };
132
+ }
113
133
  subscribeToConnectionState(handler) {
114
134
  this.connectionStateHandlers.add(handler);
115
135
  return () => {
@@ -177,6 +197,7 @@ export class GonvexClient {
177
197
  if (this.socket !== socket || this.manuallyClosed)
178
198
  return;
179
199
  this.isWebSocketConnected = false;
200
+ this.markSyncSubscriptionsOutOfDate();
180
201
  this.authInFlight = false;
181
202
  if (this.authWatchdogTimer) {
182
203
  clearTimeout(this.authWatchdogTimer);
@@ -260,12 +281,19 @@ export class GonvexClient {
260
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 }));
261
282
  for (const subscription of this.syncSubscriptions.values()) {
262
283
  this.clearSyncRetry(subscription);
284
+ if (subscription.unsubscribeTimer)
285
+ clearTimeout(subscription.unsubscribeTimer);
263
286
  }
264
287
  if (this.syncOpenFlushTimer) {
265
288
  clearTimeout(this.syncOpenFlushTimer);
266
289
  this.syncOpenFlushTimer = undefined;
267
290
  }
268
291
  this.pendingSyncOpens.clear();
292
+ if (this.querySubscribeFlushTimer) {
293
+ clearTimeout(this.querySubscribeFlushTimer);
294
+ this.querySubscribeFlushTimer = undefined;
295
+ }
296
+ this.pendingQuerySubscribes.clear();
269
297
  for (const subscription of this.querySubscriptions.values()) {
270
298
  if (subscription.cacheReadFallbackTimer)
271
299
  clearTimeout(subscription.cacheReadFallbackTimer);
@@ -420,6 +448,13 @@ export class GonvexClient {
420
448
  }
421
449
  normalizeSubscriptionMessage(subscription, message) {
422
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
+ }
423
458
  if (!this.acceptRevision(subscription, message.throughRevision))
424
459
  return undefined;
425
460
  subscription.lastRevision = message.throughRevision;
@@ -526,6 +561,10 @@ export class GonvexClient {
526
561
  const key = querySubscriptionKey(ref, args);
527
562
  const existing = this.syncSubscriptions.get(key);
528
563
  if (existing) {
564
+ if (existing.unsubscribeTimer) {
565
+ clearTimeout(existing.unsubscribeTimer);
566
+ existing.unsubscribeTimer = undefined;
567
+ }
529
568
  existing.listeners.add(onMessage);
530
569
  if (existing.lastMessage) {
531
570
  queueMicrotask(() => {
@@ -546,6 +585,10 @@ export class GonvexClient {
546
585
  opening: false,
547
586
  persistence: Promise.resolve(),
548
587
  retryAttempt: 0,
588
+ isUpToDate: false,
589
+ hashes: {},
590
+ forceFullIntegrity: false,
591
+ verificationGeneration: 0,
549
592
  };
550
593
  this.syncSubscriptions.set(key, subscription);
551
594
  this.handlers.set(subscription.id, (message) => this.handleSyncMessage(subscription, message));
@@ -555,7 +598,8 @@ export class GonvexClient {
555
598
  watchSync(ref, args = {}) {
556
599
  let latest;
557
600
  let latestError;
558
- let isUpToDate = false;
601
+ const thisClient = this;
602
+ const key = querySubscriptionKey(ref, args);
559
603
  const updateHandlers = new Set();
560
604
  const notify = () => {
561
605
  for (const handler of updateHandlers)
@@ -568,7 +612,10 @@ export class GonvexClient {
568
612
  notify();
569
613
  }
570
614
  else if (message.type === "sync.ready") {
571
- isUpToDate = true;
615
+ latestError = undefined;
616
+ notify();
617
+ }
618
+ else if (message.type === "sync.syncing" || message.type === "sync.reset") {
572
619
  notify();
573
620
  }
574
621
  else if (message.type === "sync.error") {
@@ -579,7 +626,6 @@ export class GonvexClient {
579
626
  const unsubscribeScope = this.onSessionScopeChange(() => {
580
627
  latest = undefined;
581
628
  latestError = undefined;
582
- isUpToDate = false;
583
629
  notify();
584
630
  });
585
631
  return {
@@ -589,7 +635,10 @@ export class GonvexClient {
589
635
  return latest;
590
636
  },
591
637
  status() {
592
- return { isLoading: latest === undefined, isUpToDate };
638
+ return {
639
+ isLoading: latest === undefined,
640
+ isUpToDate: thisClient.syncSubscriptions.get(key)?.isUpToDate === true,
641
+ };
593
642
  },
594
643
  onUpdate(handler) {
595
644
  updateHandlers.add(handler);
@@ -605,7 +654,18 @@ export class GonvexClient {
605
654
  }
606
655
  handleSyncMessage(subscription, message) {
607
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;
608
666
  this.clearSyncRetry(subscription, true);
667
+ subscription.verificationGeneration += 1;
668
+ subscription.isUpToDate = false;
609
669
  subscription.opening = false;
610
670
  subscription.cursor = message.cursor;
611
671
  subscription.keyField = message.key;
@@ -615,6 +675,9 @@ export class GonvexClient {
615
675
  subscription.maxRows = message.maxRows;
616
676
  subscription.maxBytes = message.maxBytes;
617
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;
618
681
  const snapshot = { ...message, result: subscription.rows };
619
682
  subscription.lastMessage = snapshot;
620
683
  this.emitSyncMessage(subscription, snapshot);
@@ -623,11 +686,20 @@ export class GonvexClient {
623
686
  }
624
687
  if (message.type === "sync.delta") {
625
688
  if (subscription.cursor && (message.cursor.epoch !== subscription.cursor.epoch
626
- || message.cursor.revision <= subscription.cursor.revision))
689
+ || message.cursor.revision < subscription.cursor.revision
690
+ || (message.cursor.revision === subscription.cursor.revision
691
+ && !message.digest)))
627
692
  return;
628
693
  this.clearSyncRetry(subscription, true);
694
+ subscription.verificationGeneration += 1;
695
+ subscription.isUpToDate = false;
629
696
  subscription.cursor = message.cursor;
630
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;
631
703
  const snapshot = {
632
704
  type: "sync.snapshot",
633
705
  id: subscription.id,
@@ -648,35 +720,128 @@ export class GonvexClient {
648
720
  }
649
721
  if (message.type === "sync.reset") {
650
722
  this.clearSyncRetry(subscription, true);
723
+ subscription.verificationGeneration += 1;
724
+ subscription.isUpToDate = false;
651
725
  subscription.cursor = undefined;
652
726
  subscription.rows = [];
727
+ subscription.hashes = {};
728
+ subscription.integrityDigest = undefined;
729
+ subscription.integrityRows = undefined;
730
+ subscription.forceFullIntegrity = false;
653
731
  subscription.lastMessage = undefined;
654
732
  subscription.opening = false;
655
733
  const directive = this.queryCacheDirective;
656
734
  const store = this.syncStore;
657
735
  if (directive && store) {
658
- 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));
659
738
  }
739
+ this.emitSyncMessage(subscription, message);
660
740
  queueMicrotask(() => this.sendSyncOpen(subscription));
661
741
  return;
662
742
  }
663
- if (message.type === "sync.ready") {
664
- 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;
665
752
  subscription.opening = false;
666
- subscription.cursor = message.cursor;
667
- subscription.mode = message.mode ?? subscription.mode;
668
- 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;
669
805
  }
670
806
  if (message.type === "sync.error") {
807
+ subscription.verificationGeneration += 1;
808
+ subscription.isUpToDate = false;
671
809
  subscription.opening = false;
672
810
  this.scheduleSyncRetry(subscription);
673
811
  }
674
812
  this.emitSyncMessage(subscription, message);
675
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
+ }
676
826
  emitSyncMessage(subscription, message) {
677
827
  for (const listener of Array.from(subscription.listeners))
678
828
  listener(message);
679
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
+ }
680
845
  startSync(subscription) {
681
846
  const directive = this.queryCacheDirective;
682
847
  const store = this.syncStore;
@@ -686,16 +851,20 @@ export class GonvexClient {
686
851
  this.sendSyncOpen(subscription);
687
852
  return;
688
853
  }
689
- const generation = this.queryCacheGeneration;
854
+ const scope = syncPersistenceScope(directive);
855
+ const generation = this.syncScopeGeneration;
690
856
  if (subscription.cacheReadGeneration === generation)
691
857
  return;
692
858
  subscription.cacheReadGeneration = generation;
693
- 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;
694
861
  if (this.syncSubscriptions.get(subscription.key) !== subscription
695
- || this.queryCacheGeneration !== generation
696
- || this.queryCacheDirective?.scope !== directive.scope)
862
+ || this.syncScopeGeneration !== generation
863
+ || !currentDirective
864
+ || syncPersistenceScope(currentDirective) !== scope)
697
865
  return;
698
866
  if (cached) {
867
+ subscription.isUpToDate = false;
699
868
  subscription.rows = cached.rows;
700
869
  subscription.cursor = cached.cursor;
701
870
  subscription.keyField = cached.keyField;
@@ -704,6 +873,12 @@ export class GonvexClient {
704
873
  subscription.orderDirection = cached.orderDirection;
705
874
  subscription.maxRows = cached.maxRows;
706
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;
707
882
  const message = {
708
883
  type: "sync.snapshot",
709
884
  id: subscription.id,
@@ -726,6 +901,38 @@ export class GonvexClient {
726
901
  sendSyncOpen(subscription) {
727
902
  if (subscription.listeners.size === 0 || subscription.opening)
728
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
+ }
729
936
  subscription.opening = true;
730
937
  subscription.socketGeneration = this.socketGeneration;
731
938
  const open = this.syncOpenRequest(subscription);
@@ -739,15 +946,23 @@ export class GonvexClient {
739
946
  this.send({ type: "sync.open", ...open });
740
947
  }
741
948
  syncOpenRequest(subscription) {
742
- const keys = subscription.mode === "eager"
743
- ? undefined
744
- : 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;
745
955
  return {
746
956
  id: subscription.id,
747
957
  path: subscription.path,
748
958
  args: subscription.args,
749
959
  cursor: subscription.cursor,
750
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,
751
966
  };
752
967
  }
753
968
  flushSyncOpens() {
@@ -759,28 +974,35 @@ export class GonvexClient {
759
974
  && subscription.listeners.size > 0
760
975
  && this.syncSubscriptions.get(subscription.key) === subscription))
761
976
  .map((subscription) => this.syncOpenRequest(subscription));
762
- if (opens.length === 0)
763
- return;
764
- 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
+ }
765
980
  }
766
981
  unsubscribeSyncListener(key, listener) {
767
982
  const subscription = this.syncSubscriptions.get(key);
768
983
  if (!subscription)
769
984
  return;
770
985
  subscription.listeners.delete(listener);
771
- if (subscription.listeners.size > 0)
986
+ if (subscription.listeners.size > 0 || subscription.unsubscribeTimer)
772
987
  return;
773
- this.clearSyncRetry(subscription);
774
- this.pendingSyncOpens.delete(subscription);
775
- this.syncSubscriptions.delete(key);
776
- this.handlers.delete(subscription.id);
777
- 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);
778
999
  }
779
1000
  persistSyncSnapshot(subscription) {
780
1001
  const directive = this.queryCacheDirective;
781
1002
  const store = this.syncStore;
782
1003
  if (!directive || !store || !subscription.cursor)
783
1004
  return;
1005
+ const scope = syncPersistenceScope(directive);
784
1006
  const value = {
785
1007
  rows: subscription.rows,
786
1008
  cursor: subscription.cursor,
@@ -790,14 +1012,16 @@ export class GonvexClient {
790
1012
  orderDirection: subscription.orderDirection,
791
1013
  maxRows: subscription.maxRows,
792
1014
  maxBytes: subscription.maxBytes,
1015
+ hashes: { ...subscription.hashes },
793
1016
  };
794
- 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));
795
1018
  }
796
1019
  persistSyncDelta(subscription, upserts, deleted) {
797
1020
  const directive = this.queryCacheDirective;
798
1021
  const store = this.syncStore;
799
1022
  if (!directive || !store || !subscription.cursor)
800
1023
  return;
1024
+ const scope = syncPersistenceScope(directive);
801
1025
  const value = {
802
1026
  cursor: subscription.cursor,
803
1027
  keyField: subscription.keyField,
@@ -808,8 +1032,9 @@ export class GonvexClient {
808
1032
  deleted,
809
1033
  maxRows: subscription.maxRows,
810
1034
  maxBytes: subscription.maxBytes,
1035
+ hashes: { ...subscription.hashes },
811
1036
  };
812
- 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));
813
1038
  }
814
1039
  mutation(ref, args = {}, options = {}) {
815
1040
  return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs);
@@ -885,11 +1110,73 @@ export class GonvexClient {
885
1110
  this.connect();
886
1111
  this.sendSubscription(subscription);
887
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
+ }
888
1158
  call(kind, ref, args, timeoutMs) {
889
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) {
890
1177
  const id = randomID();
891
1178
  const clientSentAtMs = nowMs();
892
- return new Promise((resolve, reject) => {
1179
+ const promise = new Promise((resolve, reject) => {
893
1180
  const pending = { id, kind, path: ref.path, reject };
894
1181
  const settle = () => {
895
1182
  if (pending.timeoutTimer)
@@ -927,14 +1214,8 @@ export class GonvexClient {
927
1214
  reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
928
1215
  }
929
1216
  });
930
- if (kind === "mutation") {
931
- this.send({ type: "mutation.call", id, path: ref.path, args, trace: { clientSentAtMs } });
932
- }
933
- else {
934
- this.send({ type: "action.call", id, path: ref.path, args, trace: { clientSentAtMs } });
935
- }
936
- this.notifyConnectionState();
937
1217
  });
1218
+ return { id, clientSentAtMs, promise };
938
1219
  }
939
1220
  unsubscribeQueryListener(key, listener) {
940
1221
  const subscription = this.querySubscriptions.get(key);
@@ -975,6 +1256,15 @@ export class GonvexClient {
975
1256
  }
976
1257
  }
977
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
+ }
978
1268
  this.send({
979
1269
  type: "query.subscribe",
980
1270
  id: subscription.id,
@@ -983,6 +1273,24 @@ export class GonvexClient {
983
1273
  cacheRevision: subscription.cachedRevision,
984
1274
  });
985
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
+ }
986
1294
  resumeQuerySubscriptions() {
987
1295
  for (const subscription of this.querySubscriptions.values()) {
988
1296
  if (subscription.listeners.size === 0)
@@ -990,11 +1298,19 @@ export class GonvexClient {
990
1298
  this.sendSubscription(subscription);
991
1299
  }
992
1300
  }
993
- enqueueSyncPersistence(subscription, operation) {
994
- 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
995
1305
  .catch(() => undefined)
996
1306
  .then(operation)
997
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
+ });
998
1314
  }
999
1315
  scheduleSyncRetry(subscription) {
1000
1316
  if (this.manuallyClosed
@@ -1024,6 +1340,10 @@ export class GonvexClient {
1024
1340
  subscription.retryAttempt = 0;
1025
1341
  }
1026
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;
1027
1347
  subscription.socketGeneration = undefined;
1028
1348
  this.sendSubscription(subscription);
1029
1349
  }
@@ -1073,12 +1393,21 @@ export class GonvexClient {
1073
1393
  this.resetQueryCacheScope();
1074
1394
  return;
1075
1395
  }
1076
- 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) {
1077
1400
  this.queryCacheDirective = value;
1078
1401
  return;
1079
1402
  }
1080
- if (this.queryCacheDirective) {
1081
- 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();
1082
1411
  }
1083
1412
  this.queryCacheDirective = value;
1084
1413
  const identity = authIdentityKey(this.auth);
@@ -1108,8 +1437,16 @@ export class GonvexClient {
1108
1437
  }
1109
1438
  resetQueryCacheScope() {
1110
1439
  const hadScope = this.queryCacheDirective !== undefined;
1111
- this.queryCacheGeneration += 1;
1112
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;
1113
1450
  this.queryCacheNegotiatedSocketGeneration = undefined;
1114
1451
  for (const subscription of this.querySubscriptions.values()) {
1115
1452
  subscription.lastMessage = undefined;
@@ -1121,17 +1458,22 @@ export class GonvexClient {
1121
1458
  subscription.cacheReadFallbackTimer = undefined;
1122
1459
  subscription.cachedRevision = undefined;
1123
1460
  }
1461
+ }
1462
+ resetSyncCacheState() {
1463
+ this.syncScopeGeneration += 1;
1124
1464
  for (const subscription of this.syncSubscriptions.values()) {
1125
1465
  this.clearSyncRetry(subscription, true);
1466
+ subscription.isUpToDate = false;
1126
1467
  subscription.rows = [];
1468
+ subscription.hashes = {};
1469
+ subscription.integrityDigest = undefined;
1470
+ subscription.integrityRows = undefined;
1471
+ subscription.forceFullIntegrity = false;
1127
1472
  subscription.cursor = undefined;
1128
1473
  subscription.lastMessage = undefined;
1129
1474
  subscription.cacheReadGeneration = undefined;
1130
1475
  subscription.opening = false;
1131
- }
1132
- if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
1133
- for (const handler of this.sessionScopeHandlers)
1134
- handler();
1476
+ subscription.verificationGeneration += 1;
1135
1477
  }
1136
1478
  }
1137
1479
  startQueryCacheRead(subscription) {
@@ -1275,7 +1617,14 @@ export class GonvexClient {
1275
1617
  return;
1276
1618
  this.authInFlight = true;
1277
1619
  this.armAuthWatchdog();
1278
- this.sendNow({ type: "auth", id: randomID(), token: this.auth.token, project: this.auth.project, tenant: this.auth.tenant });
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
+ });
1279
1628
  }
1280
1629
  // A lost auth reply (e.g. the server swapped its app plugin and dropped
1281
1630
  // in-flight responses while the socket stayed up) used to leave
@@ -1350,16 +1699,31 @@ function countPendingCalls(calls, kind) {
1350
1699
  return count;
1351
1700
  }
1352
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
+ }
1353
1707
  if (value === null || typeof value !== "object")
1354
1708
  return JSON.stringify(value);
1355
1709
  if (Array.isArray(value))
1356
1710
  return `[${value.map(stableStringify).join(",")}]`;
1357
1711
  const record = value;
1358
1712
  return `{${Object.keys(record)
1359
- .sort()
1360
- .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
1713
+ .sort(utf8KeyCompare)
1714
+ .map((key) => `${stableStringify(key)}:${stableStringify(record[key])}`)
1361
1715
  .join(",")}}`;
1362
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
+ }
1363
1727
  function sameRevision(left, right) {
1364
1728
  return !!right && left.epoch === right.epoch && left.sequence === right.sequence;
1365
1729
  }
@@ -1420,7 +1784,7 @@ function syncRowKey(value, keyField) {
1420
1784
  return key === null || key === undefined ? "" : String(key);
1421
1785
  }
1422
1786
  function syncJSONSize(value) {
1423
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
1787
+ return new TextEncoder().encode(stableStringify(value)).byteLength;
1424
1788
  }
1425
1789
  function applyKeyedPatch(previous, patch) {
1426
1790
  const rows = new Map();
@@ -1511,12 +1875,25 @@ function validQueryCacheDirective(value) {
1511
1875
  return value.protocolVersion === 1
1512
1876
  && typeof value.scope === "string"
1513
1877
  && value.scope.length >= 16
1878
+ && (value.syncScope === undefined
1879
+ || (typeof value.syncScope === "string" && value.syncScope.length >= 16))
1514
1880
  && typeof value.epoch === "string"
1515
1881
  && value.epoch.length >= 16
1516
1882
  && typeof value.maxAgeMs === "number"
1517
1883
  && Number.isFinite(value.maxAgeMs)
1518
1884
  && value.maxAgeMs > 0;
1519
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
+ }
1520
1897
  function isJsonRecord(value) {
1521
1898
  return value !== null && typeof value === "object" && !Array.isArray(value);
1522
1899
  }