@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/README.md +37 -0
- package/dist/index.d.ts +36 -1
- package/dist/index.js +429 -52
- 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,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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
664
|
-
|
|
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.
|
|
667
|
-
subscription
|
|
668
|
-
|
|
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
|
|
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(
|
|
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.
|
|
696
|
-
||
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1081
|
-
|
|
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({
|
|
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) => `${
|
|
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(
|
|
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
|
}
|