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