@gonvex/client 0.1.19 → 0.1.21

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,131 @@ 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
+ // Every emitted ready frame is self-describing: when a legacy runtime
825
+ // omitted the digest, the locally verified one is stamped in so consumers
826
+ // observe one contract regardless of the peer's protocol generation.
827
+ this.emitSyncMessage(subscription, message.digest === verifiedDigest ? message : { ...message, digest: verifiedDigest });
828
+ }
676
829
  emitSyncMessage(subscription, message) {
677
830
  for (const listener of Array.from(subscription.listeners))
678
831
  listener(message);
679
832
  }
833
+ markSyncSubscriptionsOutOfDate() {
834
+ for (const subscription of this.syncSubscriptions.values()) {
835
+ const wasUpToDate = subscription.isUpToDate;
836
+ subscription.verificationGeneration += 1;
837
+ subscription.isUpToDate = false;
838
+ if (!wasUpToDate)
839
+ continue;
840
+ this.emitSyncMessage(subscription, {
841
+ type: "sync.syncing",
842
+ id: subscription.id,
843
+ path: subscription.path,
844
+ reason: "disconnected",
845
+ });
846
+ }
847
+ }
680
848
  startSync(subscription) {
681
849
  const directive = this.queryCacheDirective;
682
850
  const store = this.syncStore;
@@ -686,16 +854,20 @@ export class GonvexClient {
686
854
  this.sendSyncOpen(subscription);
687
855
  return;
688
856
  }
689
- const generation = this.queryCacheGeneration;
857
+ const scope = syncPersistenceScope(directive);
858
+ const generation = this.syncScopeGeneration;
690
859
  if (subscription.cacheReadGeneration === generation)
691
860
  return;
692
861
  subscription.cacheReadGeneration = generation;
693
- void store.load(directive.scope, subscription.path, subscription.args).then((cached) => {
862
+ void store.load(scope, subscription.path, subscription.args).then((cached) => {
863
+ const currentDirective = this.queryCacheDirective;
694
864
  if (this.syncSubscriptions.get(subscription.key) !== subscription
695
- || this.queryCacheGeneration !== generation
696
- || this.queryCacheDirective?.scope !== directive.scope)
865
+ || this.syncScopeGeneration !== generation
866
+ || !currentDirective
867
+ || syncPersistenceScope(currentDirective) !== scope)
697
868
  return;
698
869
  if (cached) {
870
+ subscription.isUpToDate = false;
699
871
  subscription.rows = cached.rows;
700
872
  subscription.cursor = cached.cursor;
701
873
  subscription.keyField = cached.keyField;
@@ -704,6 +876,12 @@ export class GonvexClient {
704
876
  subscription.orderDirection = cached.orderDirection;
705
877
  subscription.maxRows = cached.maxRows;
706
878
  subscription.maxBytes = cached.maxBytes;
879
+ // Stored hash metadata is never trusted. sendSyncOpen hashes these
880
+ // actual materialized rows before advertising a cursor, which allows a
881
+ // corrupt row to be repaired by delta without a full cache reset.
882
+ subscription.hashes = {};
883
+ subscription.integrityDigest = undefined;
884
+ subscription.integrityRows = undefined;
707
885
  const message = {
708
886
  type: "sync.snapshot",
709
887
  id: subscription.id,
@@ -726,6 +904,38 @@ export class GonvexClient {
726
904
  sendSyncOpen(subscription) {
727
905
  if (subscription.listeners.size === 0 || subscription.opening)
728
906
  return;
907
+ if (subscription.cursor && subscription.integrityRows !== subscription.rows) {
908
+ subscription.opening = true;
909
+ const rows = subscription.rows;
910
+ const keyField = subscription.keyField;
911
+ const socketGeneration = this.socketGeneration;
912
+ void syncRowsHashes(rows, keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ hashes, digest })))).then(({ hashes, digest }) => {
913
+ if (this.socketGeneration !== socketGeneration
914
+ || this.syncSubscriptions.get(subscription.key) !== subscription
915
+ || subscription.listeners.size === 0
916
+ || subscription.rows !== rows
917
+ || subscription.keyField !== keyField)
918
+ return;
919
+ subscription.hashes = hashes;
920
+ subscription.integrityDigest = digest;
921
+ subscription.integrityRows = rows;
922
+ subscription.opening = false;
923
+ this.sendSyncOpen(subscription);
924
+ }).catch(() => {
925
+ if (this.socketGeneration !== socketGeneration
926
+ || this.syncSubscriptions.get(subscription.key) !== subscription
927
+ || subscription.rows !== rows)
928
+ return;
929
+ subscription.opening = false;
930
+ this.handleSyncMessage(subscription, {
931
+ type: "sync.reset",
932
+ id: subscription.id,
933
+ path: subscription.path,
934
+ reason: "integrity-mismatch",
935
+ });
936
+ });
937
+ return;
938
+ }
729
939
  subscription.opening = true;
730
940
  subscription.socketGeneration = this.socketGeneration;
731
941
  const open = this.syncOpenRequest(subscription);
@@ -739,15 +949,23 @@ export class GonvexClient {
739
949
  this.send({ type: "sync.open", ...open });
740
950
  }
741
951
  syncOpenRequest(subscription) {
742
- const keys = subscription.mode === "eager"
743
- ? undefined
744
- : subscription.rows.map((row) => syncRowKey(row, subscription.keyField)).filter(Boolean);
952
+ const fullIntegrity = subscription.cursor !== undefined && (subscription.forceFullIntegrity
953
+ || !subscription.integrityDigest
954
+ || subscription.rows.length <= compactSyncIntegrityThreshold);
955
+ const keys = fullIntegrity
956
+ ? subscription.rows.map((row) => syncRowKey(row, subscription.keyField)).filter(Boolean)
957
+ : undefined;
745
958
  return {
746
959
  id: subscription.id,
747
960
  path: subscription.path,
748
961
  args: subscription.args,
749
962
  cursor: subscription.cursor,
750
963
  keys,
964
+ hashes: fullIntegrity && Object.keys(subscription.hashes).length > 0
965
+ ? subscription.hashes
966
+ : undefined,
967
+ digest: subscription.cursor ? subscription.integrityDigest : undefined,
968
+ fullIntegrity: fullIntegrity || undefined,
751
969
  };
752
970
  }
753
971
  flushSyncOpens() {
@@ -759,28 +977,35 @@ export class GonvexClient {
759
977
  && subscription.listeners.size > 0
760
978
  && this.syncSubscriptions.get(subscription.key) === subscription))
761
979
  .map((subscription) => this.syncOpenRequest(subscription));
762
- if (opens.length === 0)
763
- return;
764
- this.send({ type: "sync.openMany", opens });
980
+ for (let offset = 0; offset < opens.length; offset += maxSyncBatchOpens) {
981
+ this.send({ type: "sync.openMany", opens: opens.slice(offset, offset + maxSyncBatchOpens) });
982
+ }
765
983
  }
766
984
  unsubscribeSyncListener(key, listener) {
767
985
  const subscription = this.syncSubscriptions.get(key);
768
986
  if (!subscription)
769
987
  return;
770
988
  subscription.listeners.delete(listener);
771
- if (subscription.listeners.size > 0)
989
+ if (subscription.listeners.size > 0 || subscription.unsubscribeTimer)
772
990
  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 });
991
+ subscription.unsubscribeTimer = setTimeout(() => {
992
+ const latest = this.syncSubscriptions.get(key);
993
+ if (!latest || latest.listeners.size > 0)
994
+ return;
995
+ latest.unsubscribeTimer = undefined;
996
+ this.clearSyncRetry(latest);
997
+ this.pendingSyncOpens.delete(latest);
998
+ this.syncSubscriptions.delete(key);
999
+ this.handlers.delete(latest.id);
1000
+ this.send({ type: "sync.close", id: latest.id });
1001
+ }, this.syncSubscriptionRetentionMs);
778
1002
  }
779
1003
  persistSyncSnapshot(subscription) {
780
1004
  const directive = this.queryCacheDirective;
781
1005
  const store = this.syncStore;
782
1006
  if (!directive || !store || !subscription.cursor)
783
1007
  return;
1008
+ const scope = syncPersistenceScope(directive);
784
1009
  const value = {
785
1010
  rows: subscription.rows,
786
1011
  cursor: subscription.cursor,
@@ -790,14 +1015,16 @@ export class GonvexClient {
790
1015
  orderDirection: subscription.orderDirection,
791
1016
  maxRows: subscription.maxRows,
792
1017
  maxBytes: subscription.maxBytes,
1018
+ hashes: { ...subscription.hashes },
793
1019
  };
794
- this.enqueueSyncPersistence(subscription, () => store.replace(directive.scope, subscription.path, subscription.args, value));
1020
+ this.enqueueSyncPersistence(subscription, scope, () => store.replace(scope, subscription.path, subscription.args, value));
795
1021
  }
796
1022
  persistSyncDelta(subscription, upserts, deleted) {
797
1023
  const directive = this.queryCacheDirective;
798
1024
  const store = this.syncStore;
799
1025
  if (!directive || !store || !subscription.cursor)
800
1026
  return;
1027
+ const scope = syncPersistenceScope(directive);
801
1028
  const value = {
802
1029
  cursor: subscription.cursor,
803
1030
  keyField: subscription.keyField,
@@ -808,8 +1035,9 @@ export class GonvexClient {
808
1035
  deleted,
809
1036
  maxRows: subscription.maxRows,
810
1037
  maxBytes: subscription.maxBytes,
1038
+ hashes: { ...subscription.hashes },
811
1039
  };
812
- this.enqueueSyncPersistence(subscription, () => store.applyDelta(directive.scope, subscription.path, subscription.args, value));
1040
+ this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
813
1041
  }
814
1042
  mutation(ref, args = {}, options = {}) {
815
1043
  return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs);
@@ -885,11 +1113,73 @@ export class GonvexClient {
885
1113
  this.connect();
886
1114
  this.sendSubscription(subscription);
887
1115
  }
1116
+ /**
1117
+ * Flush a queue of mutations in one `mutation.callMany` frame (queue order,
1118
+ * one websocket round trip). Each entry settles independently — a failed
1119
+ * call does not reject the batch — so offline queues can apply per-row
1120
+ * outcomes. Falls back to sequential `mutation` calls on runtimes that do
1121
+ * not advertise the `mutationBatch` capability.
1122
+ */
1123
+ async mutationMany(calls, options = {}) {
1124
+ if (calls.length === 0)
1125
+ return [];
1126
+ this.connect();
1127
+ const timeoutMs = options.timeoutMs ?? this.timeouts.mutationTimeoutMs;
1128
+ const settle = (promise, path) => promise
1129
+ .then((result) => ({ status: "ok", result }))
1130
+ .catch((error) => ({
1131
+ status: "error",
1132
+ error: error instanceof GonvexClientError
1133
+ ? error
1134
+ : new GonvexClientError(String(error), { code: "server", path, operation: "mutation" }),
1135
+ }));
1136
+ if (this.serverCapabilities.mutationBatch !== 1) {
1137
+ const outcomes = [];
1138
+ for (const call of calls) {
1139
+ outcomes.push(await settle(this.mutation(call.ref, call.args ?? {}, options), call.ref.path));
1140
+ }
1141
+ return outcomes;
1142
+ }
1143
+ const registered = calls.map((call) => {
1144
+ const entry = this.registerCall("mutation", call.ref, call.args ?? {}, timeoutMs);
1145
+ return { ...entry, path: call.ref.path, args: call.args ?? {} };
1146
+ });
1147
+ for (let offset = 0; offset < registered.length; offset += maxSyncBatchOpens) {
1148
+ this.send({
1149
+ type: "mutation.callMany",
1150
+ calls: registered.slice(offset, offset + maxSyncBatchOpens).map((entry) => ({
1151
+ id: entry.id,
1152
+ path: entry.path,
1153
+ args: entry.args,
1154
+ trace: { clientSentAtMs: entry.clientSentAtMs },
1155
+ })),
1156
+ });
1157
+ }
1158
+ this.notifyConnectionState();
1159
+ return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
1160
+ }
888
1161
  call(kind, ref, args, timeoutMs) {
889
1162
  this.connect();
1163
+ const entry = this.registerCall(kind, ref, args, timeoutMs);
1164
+ if (kind === "mutation") {
1165
+ try {
1166
+ const w = globalThis;
1167
+ if (w && w.__wsTapLog)
1168
+ 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 });
1169
+ }
1170
+ catch (e) { }
1171
+ this.send({ type: "mutation.call", id: entry.id, path: ref.path, args, trace: { clientSentAtMs: entry.clientSentAtMs } });
1172
+ }
1173
+ else {
1174
+ this.send({ type: "action.call", id: entry.id, path: ref.path, args, trace: { clientSentAtMs: entry.clientSentAtMs } });
1175
+ }
1176
+ this.notifyConnectionState();
1177
+ return entry.promise;
1178
+ }
1179
+ registerCall(kind, ref, args, timeoutMs) {
890
1180
  const id = randomID();
891
1181
  const clientSentAtMs = nowMs();
892
- return new Promise((resolve, reject) => {
1182
+ const promise = new Promise((resolve, reject) => {
893
1183
  const pending = { id, kind, path: ref.path, reject };
894
1184
  const settle = () => {
895
1185
  if (pending.timeoutTimer)
@@ -927,14 +1217,8 @@ export class GonvexClient {
927
1217
  reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
928
1218
  }
929
1219
  });
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
1220
  });
1221
+ return { id, clientSentAtMs, promise };
938
1222
  }
939
1223
  unsubscribeQueryListener(key, listener) {
940
1224
  const subscription = this.querySubscriptions.get(key);
@@ -975,6 +1259,15 @@ export class GonvexClient {
975
1259
  }
976
1260
  }
977
1261
  subscription.socketGeneration = this.socketGeneration;
1262
+ // Route reloads register dozens of live queries at once. Collapse the
1263
+ // burst into one batched frame per tick instead of one frame per query.
1264
+ if (this.serverCapabilities.queryBatch === 1) {
1265
+ this.pendingQuerySubscribes.add(subscription);
1266
+ if (!this.querySubscribeFlushTimer) {
1267
+ this.querySubscribeFlushTimer = setTimeout(() => this.flushQuerySubscribes(), 0);
1268
+ }
1269
+ return;
1270
+ }
978
1271
  this.send({
979
1272
  type: "query.subscribe",
980
1273
  id: subscription.id,
@@ -983,6 +1276,24 @@ export class GonvexClient {
983
1276
  cacheRevision: subscription.cachedRevision,
984
1277
  });
985
1278
  }
1279
+ flushQuerySubscribes() {
1280
+ this.querySubscribeFlushTimer = undefined;
1281
+ const subscriptions = Array.from(this.pendingQuerySubscribes);
1282
+ this.pendingQuerySubscribes.clear();
1283
+ const subscribes = subscriptions
1284
+ .filter((subscription) => (subscription.listeners.size > 0
1285
+ && subscription.socketGeneration === this.socketGeneration
1286
+ && this.querySubscriptions.get(subscription.key) === subscription))
1287
+ .map((subscription) => ({
1288
+ id: subscription.id,
1289
+ path: subscription.path,
1290
+ args: subscription.args,
1291
+ cacheRevision: subscription.cachedRevision,
1292
+ }));
1293
+ for (let offset = 0; offset < subscribes.length; offset += maxSyncBatchOpens) {
1294
+ this.send({ type: "query.subscribeMany", subscribes: subscribes.slice(offset, offset + maxSyncBatchOpens) });
1295
+ }
1296
+ }
986
1297
  resumeQuerySubscriptions() {
987
1298
  for (const subscription of this.querySubscriptions.values()) {
988
1299
  if (subscription.listeners.size === 0)
@@ -990,11 +1301,19 @@ export class GonvexClient {
990
1301
  this.sendSubscription(subscription);
991
1302
  }
992
1303
  }
993
- enqueueSyncPersistence(subscription, operation) {
994
- subscription.persistence = subscription.persistence
1304
+ enqueueSyncPersistence(subscription, scope, operation) {
1305
+ const key = `${scope}\u0000${subscription.key}`;
1306
+ const previous = this.syncPersistence.get(key) ?? Promise.resolve();
1307
+ const pending = previous
995
1308
  .catch(() => undefined)
996
1309
  .then(operation)
997
1310
  .catch(() => undefined);
1311
+ this.syncPersistence.set(key, pending);
1312
+ subscription.persistence = pending;
1313
+ void pending.finally(() => {
1314
+ if (this.syncPersistence.get(key) === pending)
1315
+ this.syncPersistence.delete(key);
1316
+ });
998
1317
  }
999
1318
  scheduleSyncRetry(subscription) {
1000
1319
  if (this.manuallyClosed
@@ -1024,6 +1343,10 @@ export class GonvexClient {
1024
1343
  subscription.retryAttempt = 0;
1025
1344
  }
1026
1345
  requestSubscriptionSnapshot(subscription) {
1346
+ // Do not advertise the cache revision while recovering. Otherwise the
1347
+ // runtime can answer with another progress frame instead of a snapshot.
1348
+ subscription.cachedRevision = undefined;
1349
+ subscription.serverSettled = false;
1027
1350
  subscription.socketGeneration = undefined;
1028
1351
  this.sendSubscription(subscription);
1029
1352
  }
@@ -1073,12 +1396,21 @@ export class GonvexClient {
1073
1396
  this.resetQueryCacheScope();
1074
1397
  return;
1075
1398
  }
1076
- if (this.queryCacheDirective?.scope === value.scope) {
1399
+ const previous = this.queryCacheDirective;
1400
+ const syncScopeChanged = previous !== undefined
1401
+ && syncPersistenceScope(previous) !== syncPersistenceScope(value);
1402
+ if (previous?.scope === value.scope && !syncScopeChanged) {
1077
1403
  this.queryCacheDirective = value;
1078
1404
  return;
1079
1405
  }
1080
- if (this.queryCacheDirective) {
1081
- this.resetQueryCacheScope();
1406
+ if (previous) {
1407
+ // A deploy rotates the query-result scope (results depend on code), but
1408
+ // sync collections are keyed by visibility and survive it: their rows,
1409
+ // cursors, and in-flight warm reads stay valid and are verified by the
1410
+ // server's reconcile on the next open.
1411
+ this.resetQueryResultCacheState();
1412
+ if (syncScopeChanged)
1413
+ this.resetSyncCacheState();
1082
1414
  }
1083
1415
  this.queryCacheDirective = value;
1084
1416
  const identity = authIdentityKey(this.auth);
@@ -1108,8 +1440,16 @@ export class GonvexClient {
1108
1440
  }
1109
1441
  resetQueryCacheScope() {
1110
1442
  const hadScope = this.queryCacheDirective !== undefined;
1111
- this.queryCacheGeneration += 1;
1112
1443
  this.queryCacheDirective = undefined;
1444
+ this.resetQueryResultCacheState();
1445
+ this.resetSyncCacheState();
1446
+ if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
1447
+ for (const handler of this.sessionScopeHandlers)
1448
+ handler();
1449
+ }
1450
+ }
1451
+ resetQueryResultCacheState() {
1452
+ this.queryCacheGeneration += 1;
1113
1453
  this.queryCacheNegotiatedSocketGeneration = undefined;
1114
1454
  for (const subscription of this.querySubscriptions.values()) {
1115
1455
  subscription.lastMessage = undefined;
@@ -1121,17 +1461,22 @@ export class GonvexClient {
1121
1461
  subscription.cacheReadFallbackTimer = undefined;
1122
1462
  subscription.cachedRevision = undefined;
1123
1463
  }
1464
+ }
1465
+ resetSyncCacheState() {
1466
+ this.syncScopeGeneration += 1;
1124
1467
  for (const subscription of this.syncSubscriptions.values()) {
1125
1468
  this.clearSyncRetry(subscription, true);
1469
+ subscription.isUpToDate = false;
1126
1470
  subscription.rows = [];
1471
+ subscription.hashes = {};
1472
+ subscription.integrityDigest = undefined;
1473
+ subscription.integrityRows = undefined;
1474
+ subscription.forceFullIntegrity = false;
1127
1475
  subscription.cursor = undefined;
1128
1476
  subscription.lastMessage = undefined;
1129
1477
  subscription.cacheReadGeneration = undefined;
1130
1478
  subscription.opening = false;
1131
- }
1132
- if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
1133
- for (const handler of this.sessionScopeHandlers)
1134
- handler();
1479
+ subscription.verificationGeneration += 1;
1135
1480
  }
1136
1481
  }
1137
1482
  startQueryCacheRead(subscription) {
@@ -1275,7 +1620,14 @@ export class GonvexClient {
1275
1620
  return;
1276
1621
  this.authInFlight = true;
1277
1622
  this.armAuthWatchdog();
1278
- this.sendNow({ type: "auth", id: randomID(), token: this.auth.token, project: this.auth.project, tenant: this.auth.tenant });
1623
+ this.sendNow({
1624
+ type: "auth",
1625
+ id: randomID(),
1626
+ token: this.auth.token,
1627
+ project: this.auth.project,
1628
+ tenant: this.auth.tenant,
1629
+ device: browserTelemetryInfo(),
1630
+ });
1279
1631
  }
1280
1632
  // A lost auth reply (e.g. the server swapped its app plugin and dropped
1281
1633
  // in-flight responses while the socket stayed up) used to leave
@@ -1350,16 +1702,31 @@ function countPendingCalls(calls, kind) {
1350
1702
  return count;
1351
1703
  }
1352
1704
  function stableStringify(value) {
1705
+ if (typeof value === "string") {
1706
+ return JSON.stringify(value)
1707
+ .replace(/\u2028/g, "\\u2028")
1708
+ .replace(/\u2029/g, "\\u2029");
1709
+ }
1353
1710
  if (value === null || typeof value !== "object")
1354
1711
  return JSON.stringify(value);
1355
1712
  if (Array.isArray(value))
1356
1713
  return `[${value.map(stableStringify).join(",")}]`;
1357
1714
  const record = value;
1358
1715
  return `{${Object.keys(record)
1359
- .sort()
1360
- .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
1716
+ .sort(utf8KeyCompare)
1717
+ .map((key) => `${stableStringify(key)}:${stableStringify(record[key])}`)
1361
1718
  .join(",")}}`;
1362
1719
  }
1720
+ function utf8KeyCompare(left, right) {
1721
+ const leftBytes = new TextEncoder().encode(left);
1722
+ const rightBytes = new TextEncoder().encode(right);
1723
+ const length = Math.min(leftBytes.length, rightBytes.length);
1724
+ for (let index = 0; index < length; index += 1) {
1725
+ if (leftBytes[index] !== rightBytes[index])
1726
+ return leftBytes[index] - rightBytes[index];
1727
+ }
1728
+ return leftBytes.length - rightBytes.length;
1729
+ }
1363
1730
  function sameRevision(left, right) {
1364
1731
  return !!right && left.epoch === right.epoch && left.sequence === right.sequence;
1365
1732
  }
@@ -1420,7 +1787,7 @@ function syncRowKey(value, keyField) {
1420
1787
  return key === null || key === undefined ? "" : String(key);
1421
1788
  }
1422
1789
  function syncJSONSize(value) {
1423
- return new TextEncoder().encode(JSON.stringify(value)).byteLength;
1790
+ return new TextEncoder().encode(stableStringify(value)).byteLength;
1424
1791
  }
1425
1792
  function applyKeyedPatch(previous, patch) {
1426
1793
  const rows = new Map();
@@ -1511,12 +1878,25 @@ function validQueryCacheDirective(value) {
1511
1878
  return value.protocolVersion === 1
1512
1879
  && typeof value.scope === "string"
1513
1880
  && value.scope.length >= 16
1881
+ && (value.syncScope === undefined
1882
+ || (typeof value.syncScope === "string" && value.syncScope.length >= 16))
1514
1883
  && typeof value.epoch === "string"
1515
1884
  && value.epoch.length >= 16
1516
1885
  && typeof value.maxAgeMs === "number"
1517
1886
  && Number.isFinite(value.maxAgeMs)
1518
1887
  && value.maxAgeMs > 0;
1519
1888
  }
1889
+ /**
1890
+ * The scope under which sync collections are persisted and resumed. Newer
1891
+ * runtimes send a visibility-only `syncScope` that survives deploys (the
1892
+ * authoritative reconcile on resume guarantees correctness across code
1893
+ * changes); older runtimes only send the bundle-epoch `scope`.
1894
+ */
1895
+ function syncPersistenceScope(directive) {
1896
+ return typeof directive.syncScope === "string" && directive.syncScope.length >= 16
1897
+ ? directive.syncScope
1898
+ : directive.scope;
1899
+ }
1520
1900
  function isJsonRecord(value) {
1521
1901
  return value !== null && typeof value === "object" && !Array.isArray(value);
1522
1902
  }