@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/README.md +37 -0
- package/dist/index.d.ts +38 -1
- package/dist/index.js +474 -54
- package/dist/index.js.map +1 -1
- package/dist/sync-store.d.ts +11 -0
- package/dist/sync-store.js +68 -5
- package/dist/sync-store.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
655
|
-
|
|
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.
|
|
658
|
-
subscription
|
|
659
|
-
|
|
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
|
|
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(
|
|
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.
|
|
687
|
-
||
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1072
|
-
|
|
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.
|
|
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
|
|
1280
|
-
socket
|
|
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) => `${
|
|
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(
|
|
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
|
}
|