@gonvex/client 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +89 -2
- package/dist/index.js +380 -16
- package/dist/index.js.map +1 -1
- package/dist/optimistic.d.ts +41 -0
- package/dist/optimistic.js +98 -0
- package/dist/optimistic.js.map +1 -0
- package/dist/outbox.d.ts +86 -0
- package/dist/outbox.js +324 -0
- package/dist/outbox.js.map +1 -0
- package/dist/signals.d.ts +20 -0
- package/dist/signals.js +259 -0
- package/dist/signals.js.map +1 -0
- package/dist/sync-store.d.ts +5 -0
- package/dist/sync-store.js +4 -0
- package/dist/sync-store.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createQueryCacheStore, defaultQueryCacheReadTimeoutMs, } from "./query-cache.js";
|
|
2
2
|
import { createSyncStore, syncHashesDigest, syncRowsHashes, } from "./sync-store.js";
|
|
3
3
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
|
+
import { OptimisticOverlay } from "./optimistic.js";
|
|
5
|
+
import { createMutationOutbox, } from "./outbox.js";
|
|
4
6
|
export * from "./cache.js";
|
|
5
7
|
export * from "./cache-coordinator.js";
|
|
6
8
|
export * from "./browser-cache.js";
|
|
@@ -11,6 +13,9 @@ export * from "./persistent-cache.js";
|
|
|
11
13
|
export * from "./query-cache.js";
|
|
12
14
|
export * from "./sync-store.js";
|
|
13
15
|
export * from "./error-reporter.js";
|
|
16
|
+
export * from "./optimistic.js";
|
|
17
|
+
export * from "./outbox.js";
|
|
18
|
+
export * from "./signals.js";
|
|
14
19
|
/**
|
|
15
20
|
* Typed error for every rejected Gonvex operation. `code` distinguishes
|
|
16
21
|
* server-side failures from transport-level ones so apps can decide whether
|
|
@@ -20,7 +25,7 @@ export * from "./error-reporter.js";
|
|
|
20
25
|
* - `timeout`: no response arrived within the operation timeout. For
|
|
21
26
|
* mutations/actions the write may or may not have been applied.
|
|
22
27
|
* - `disconnected`: the socket dropped while the operation was pending.
|
|
23
|
-
* Mutations/actions fail closed
|
|
28
|
+
* Mutations/actions fail closed unless a mutation opted into the outbox.
|
|
24
29
|
* - `closed`: the client was explicitly closed.
|
|
25
30
|
* - `auth`: authentication was rejected.
|
|
26
31
|
*/
|
|
@@ -54,6 +59,9 @@ const maxSyncBatchOpens = 256;
|
|
|
54
59
|
// start into a cold open — never into a permanently empty screen. Reads
|
|
55
60
|
// normally settle in a few milliseconds.
|
|
56
61
|
const syncStoreReadTimeoutMs = 1_000;
|
|
62
|
+
// Watermarks can arrive for every tenant revision. Bound cursor-only IndexedDB
|
|
63
|
+
// writes per collection while keeping the in-memory resume cursor immediate.
|
|
64
|
+
const syncWatermarkPersistDelayMs = 1_000;
|
|
57
65
|
export class GonvexClient {
|
|
58
66
|
url;
|
|
59
67
|
socket;
|
|
@@ -72,6 +80,14 @@ export class GonvexClient {
|
|
|
72
80
|
auth = {};
|
|
73
81
|
authInFlight = false;
|
|
74
82
|
authWatchdogTimer;
|
|
83
|
+
// Monotonic guard for async token fetches: a resolve whose generation is no
|
|
84
|
+
// longer current was superseded (newer setAuth, watchdog re-issue, or a
|
|
85
|
+
// reconnect's own fetch) and must be discarded.
|
|
86
|
+
authFetchGeneration = 0;
|
|
87
|
+
// At most one forced refresh per rejection cycle; cleared when auth settles
|
|
88
|
+
// or a fresh send cycle starts, so a bad token can't refresh-loop forever.
|
|
89
|
+
authRetriedAfterError = false;
|
|
90
|
+
authErrorHandlers = new Set();
|
|
75
91
|
telemetryEnabled = false;
|
|
76
92
|
queryCache;
|
|
77
93
|
queryCacheWaitForScope;
|
|
@@ -79,6 +95,14 @@ export class GonvexClient {
|
|
|
79
95
|
querySubscriptionRetentionMs;
|
|
80
96
|
syncSubscriptionRetentionMs;
|
|
81
97
|
syncStore;
|
|
98
|
+
mutationOutbox;
|
|
99
|
+
overlay = new OptimisticOverlay();
|
|
100
|
+
optimisticMutationIds = new Set();
|
|
101
|
+
outboxReady;
|
|
102
|
+
unsubscribeOutbox;
|
|
103
|
+
unsubscribeOverlay;
|
|
104
|
+
drainingOutbox = false;
|
|
105
|
+
outboxDrainTimer;
|
|
82
106
|
queryCacheDirective;
|
|
83
107
|
queryCacheGeneration = 0;
|
|
84
108
|
// Sync collections live under a visibility-only scope that survives query
|
|
@@ -108,6 +132,14 @@ export class GonvexClient {
|
|
|
108
132
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
109
133
|
this.syncSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.syncSubscriptionRetentionMs);
|
|
110
134
|
this.syncStore = createSyncStore(options.sync);
|
|
135
|
+
this.mutationOutbox = createMutationOutbox(options.outbox);
|
|
136
|
+
this.unsubscribeOutbox = this.mutationOutbox.subscribe(() => {
|
|
137
|
+
void this.drainOutbox();
|
|
138
|
+
});
|
|
139
|
+
this.unsubscribeOverlay = this.overlay.subscribe((collection) => {
|
|
140
|
+
this.emitOptimisticCollection(collection);
|
|
141
|
+
});
|
|
142
|
+
this.outboxReady = this.restoreOutbox();
|
|
111
143
|
this.timeouts = {
|
|
112
144
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
113
145
|
mutationTimeoutMs: options.timeouts?.mutationTimeoutMs ?? DEFAULT_MUTATION_TIMEOUT_MS,
|
|
@@ -118,6 +150,15 @@ export class GonvexClient {
|
|
|
118
150
|
}
|
|
119
151
|
this.recoverWarmSyncDirective();
|
|
120
152
|
}
|
|
153
|
+
/** The client's materialized optimistic state for pending-row indicators. */
|
|
154
|
+
get optimisticOverlay() {
|
|
155
|
+
return this.overlay;
|
|
156
|
+
}
|
|
157
|
+
/** Number of mutations waiting for a definitive server result. */
|
|
158
|
+
async outboxCount() {
|
|
159
|
+
await this.outboxReady;
|
|
160
|
+
return this.mutationOutbox.count();
|
|
161
|
+
}
|
|
121
162
|
connectionState() {
|
|
122
163
|
const inflightMutations = countPendingCalls(this.pendingCalls, "mutation");
|
|
123
164
|
const inflightActions = countPendingCalls(this.pendingCalls, "action");
|
|
@@ -152,6 +193,29 @@ export class GonvexClient {
|
|
|
152
193
|
}
|
|
153
194
|
}
|
|
154
195
|
setAuth(auth) {
|
|
196
|
+
this.applyAuth(auth);
|
|
197
|
+
// The caller owns auth now: a token fetch still in flight from the
|
|
198
|
+
// previous installation must not clobber this one when it resolves.
|
|
199
|
+
this.authFetchGeneration += 1;
|
|
200
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
201
|
+
// A token supplied in this very call was just minted by the caller —
|
|
202
|
+
// send it as-is instead of paying another fetch round trip.
|
|
203
|
+
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Subscribe to unrecoverable auth rejections: the server refused the
|
|
208
|
+
* credentials and, when a token fetcher is installed, a force-refreshed
|
|
209
|
+
* token did not fix it. Lets apps route to sign-in instead of silently
|
|
210
|
+
* degrading to an unauthenticated session.
|
|
211
|
+
*/
|
|
212
|
+
onAuthError(handler) {
|
|
213
|
+
this.authErrorHandlers.add(handler);
|
|
214
|
+
return () => {
|
|
215
|
+
this.authErrorHandlers.delete(handler);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
applyAuth(auth) {
|
|
155
219
|
const nextAuth = { ...this.auth, ...auth };
|
|
156
220
|
const tokenScopeChanged = hasOwn(auth, "token")
|
|
157
221
|
&& auth.token !== this.auth.token
|
|
@@ -176,9 +240,6 @@ export class GonvexClient {
|
|
|
176
240
|
if (auth.telemetry !== undefined) {
|
|
177
241
|
this.telemetryEnabled = auth.telemetry === true;
|
|
178
242
|
}
|
|
179
|
-
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
180
|
-
this.sendAuth(true);
|
|
181
|
-
}
|
|
182
243
|
}
|
|
183
244
|
connect() {
|
|
184
245
|
if (this.socket && this.socket.readyState <= WebSocket.OPEN)
|
|
@@ -202,6 +263,7 @@ export class GonvexClient {
|
|
|
202
263
|
this.sendAuth(false);
|
|
203
264
|
if (isReconnect)
|
|
204
265
|
this.resubscribeQueries(generation);
|
|
266
|
+
void this.drainOutbox();
|
|
205
267
|
this.notifyConnectionState();
|
|
206
268
|
});
|
|
207
269
|
socket.addEventListener("close", () => {
|
|
@@ -258,18 +320,39 @@ export class GonvexClient {
|
|
|
258
320
|
this.authWatchdogTimer = undefined;
|
|
259
321
|
}
|
|
260
322
|
if (message.type === "auth.result") {
|
|
323
|
+
this.authRetriedAfterError = false;
|
|
261
324
|
this.installQueryCacheDirective(queryCacheDirectiveFromAuthResult(message.result));
|
|
262
325
|
this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
|
|
263
326
|
this.resumeQuerySubscriptions();
|
|
264
327
|
}
|
|
265
328
|
else {
|
|
329
|
+
const fetcher = this.auth.fetchToken;
|
|
330
|
+
if (fetcher && !this.authRetriedAfterError) {
|
|
331
|
+
// The installed token was rejected — typically expired while the
|
|
332
|
+
// socket was down. Force-refresh through the fetcher and retry
|
|
333
|
+
// once before treating the rejection as final.
|
|
334
|
+
this.authRetriedAfterError = true;
|
|
335
|
+
this.authInFlight = true;
|
|
336
|
+
this.armAuthWatchdog();
|
|
337
|
+
void this.refreshRejectedAuth(fetcher, this.auth.token, message.error);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this.authRetriedAfterError = false;
|
|
266
341
|
this.resetQueryCacheScope();
|
|
342
|
+
this.notifyAuthError(message.error);
|
|
267
343
|
}
|
|
268
344
|
this.flushPendingMessages();
|
|
269
345
|
}
|
|
270
346
|
if (message.type === "sync.readyMany") {
|
|
271
347
|
for (const ready of message.ready) {
|
|
272
|
-
|
|
348
|
+
const readyMessage = { type: "sync.ready", ...ready };
|
|
349
|
+
this.handlers.get(ready.id)?.(readyMessage);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (message.type === "sync.watermark") {
|
|
354
|
+
if (this.serverCapabilities.syncWatermark === 1) {
|
|
355
|
+
this.handleSyncWatermark(message.revision);
|
|
273
356
|
}
|
|
274
357
|
return;
|
|
275
358
|
}
|
|
@@ -294,6 +377,11 @@ export class GonvexClient {
|
|
|
294
377
|
this.clearSyncRetry(subscription);
|
|
295
378
|
if (subscription.unsubscribeTimer)
|
|
296
379
|
clearTimeout(subscription.unsubscribeTimer);
|
|
380
|
+
if (subscription.watermarkPersistTimer) {
|
|
381
|
+
clearTimeout(subscription.watermarkPersistTimer);
|
|
382
|
+
subscription.watermarkPersistTimer = undefined;
|
|
383
|
+
this.persistSyncSnapshot(subscription, true);
|
|
384
|
+
}
|
|
297
385
|
}
|
|
298
386
|
if (this.syncOpenFlushTimer) {
|
|
299
387
|
clearTimeout(this.syncOpenFlushTimer);
|
|
@@ -305,6 +393,12 @@ export class GonvexClient {
|
|
|
305
393
|
this.querySubscribeFlushTimer = undefined;
|
|
306
394
|
}
|
|
307
395
|
this.pendingQuerySubscribes.clear();
|
|
396
|
+
if (this.outboxDrainTimer) {
|
|
397
|
+
clearTimeout(this.outboxDrainTimer);
|
|
398
|
+
this.outboxDrainTimer = undefined;
|
|
399
|
+
}
|
|
400
|
+
this.unsubscribeOutbox();
|
|
401
|
+
this.unsubscribeOverlay();
|
|
308
402
|
for (const subscription of this.querySubscriptions.values()) {
|
|
309
403
|
if (subscription.cacheReadFallbackTimer)
|
|
310
404
|
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
@@ -313,6 +407,10 @@ export class GonvexClient {
|
|
|
313
407
|
this.querySubscriptions.clear();
|
|
314
408
|
this.syncSubscriptions.clear();
|
|
315
409
|
this.sessionScopeHandlers.clear();
|
|
410
|
+
this.authErrorHandlers.clear();
|
|
411
|
+
// Invalidate any token fetch still in flight so its resolve can't touch
|
|
412
|
+
// the closed client's caches.
|
|
413
|
+
this.authFetchGeneration += 1;
|
|
316
414
|
this.queryCacheGeneration += 1;
|
|
317
415
|
this.queryCacheDirective = undefined;
|
|
318
416
|
this.queryCache?.close();
|
|
@@ -579,8 +677,9 @@ export class GonvexClient {
|
|
|
579
677
|
existing.listeners.add(onMessage);
|
|
580
678
|
if (existing.lastMessage) {
|
|
581
679
|
queueMicrotask(() => {
|
|
582
|
-
if (existing.listeners.has(onMessage) && existing.lastMessage)
|
|
583
|
-
onMessage(existing.lastMessage);
|
|
680
|
+
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
681
|
+
onMessage(this.materializeSyncMessage(existing, existing.lastMessage));
|
|
682
|
+
}
|
|
584
683
|
});
|
|
585
684
|
}
|
|
586
685
|
return () => this.unsubscribeSyncListener(key, onMessage);
|
|
@@ -681,6 +780,7 @@ export class GonvexClient {
|
|
|
681
780
|
subscription.cursor = message.cursor;
|
|
682
781
|
subscription.keyField = message.key;
|
|
683
782
|
subscription.mode = message.mode;
|
|
783
|
+
subscription.truncated = undefined;
|
|
684
784
|
subscription.orderBy = message.orderBy;
|
|
685
785
|
subscription.orderDirection = message.orderDirection;
|
|
686
786
|
subscription.maxRows = message.maxRows;
|
|
@@ -689,6 +789,7 @@ export class GonvexClient {
|
|
|
689
789
|
subscription.hashes = { ...(message.hashes ?? {}) };
|
|
690
790
|
subscription.integrityDigest = undefined;
|
|
691
791
|
subscription.integrityRows = undefined;
|
|
792
|
+
subscription.integrityEpoch = undefined;
|
|
692
793
|
const snapshot = { ...message, result: subscription.rows };
|
|
693
794
|
subscription.lastMessage = snapshot;
|
|
694
795
|
this.emitSyncMessage(subscription, snapshot);
|
|
@@ -711,6 +812,7 @@ export class GonvexClient {
|
|
|
711
812
|
Object.assign(subscription.hashes, message.hashes ?? {});
|
|
712
813
|
subscription.integrityDigest = undefined;
|
|
713
814
|
subscription.integrityRows = undefined;
|
|
815
|
+
subscription.integrityEpoch = undefined;
|
|
714
816
|
const snapshot = {
|
|
715
817
|
type: "sync.snapshot",
|
|
716
818
|
id: subscription.id,
|
|
@@ -731,14 +833,20 @@ export class GonvexClient {
|
|
|
731
833
|
}
|
|
732
834
|
if (message.type === "sync.reset") {
|
|
733
835
|
this.clearSyncRetry(subscription, true);
|
|
836
|
+
if (subscription.watermarkPersistTimer) {
|
|
837
|
+
clearTimeout(subscription.watermarkPersistTimer);
|
|
838
|
+
subscription.watermarkPersistTimer = undefined;
|
|
839
|
+
}
|
|
734
840
|
subscription.verificationGeneration += 1;
|
|
735
841
|
subscription.isUpToDate = false;
|
|
736
842
|
subscription.cursor = undefined;
|
|
843
|
+
subscription.truncated = undefined;
|
|
737
844
|
subscription.rows = [];
|
|
738
845
|
subscription.persistedRows = undefined;
|
|
739
846
|
subscription.hashes = {};
|
|
740
847
|
subscription.integrityDigest = undefined;
|
|
741
848
|
subscription.integrityRows = undefined;
|
|
849
|
+
subscription.integrityEpoch = undefined;
|
|
742
850
|
subscription.forceFullIntegrity = false;
|
|
743
851
|
subscription.lastMessage = undefined;
|
|
744
852
|
subscription.opening = false;
|
|
@@ -786,6 +894,20 @@ export class GonvexClient {
|
|
|
786
894
|
});
|
|
787
895
|
return;
|
|
788
896
|
}
|
|
897
|
+
if (!subscription.forceFullIntegrity
|
|
898
|
+
&& subscription.integrityRows === subscription.rows
|
|
899
|
+
&& subscription.integrityDigest
|
|
900
|
+
&& subscription.integrityEpoch === subscription.cursor.epoch) {
|
|
901
|
+
if (message.digest && subscription.integrityDigest !== message.digest) {
|
|
902
|
+
// Re-hash once before treating the server/memo disagreement as an
|
|
903
|
+
// integrity failure. The memo may be stale even though row identity
|
|
904
|
+
// says the collection has not changed.
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
this.acceptSyncReady(subscription, message, subscription.integrityDigest);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
789
911
|
void syncRowsHashes(subscription.rows, subscription.keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ digest, hashes })))).then(({ digest, hashes }) => {
|
|
790
912
|
if (generation !== subscription.verificationGeneration
|
|
791
913
|
|| this.syncSubscriptions.get(subscription.key) !== subscription)
|
|
@@ -829,8 +951,10 @@ export class GonvexClient {
|
|
|
829
951
|
subscription.opening = false;
|
|
830
952
|
subscription.cursor = message.cursor;
|
|
831
953
|
subscription.mode = message.mode ?? subscription.mode;
|
|
954
|
+
subscription.truncated = message.truncated;
|
|
832
955
|
subscription.integrityDigest = verifiedDigest;
|
|
833
956
|
subscription.integrityRows = subscription.rows;
|
|
957
|
+
subscription.integrityEpoch = message.cursor.epoch;
|
|
834
958
|
subscription.forceFullIntegrity = false;
|
|
835
959
|
this.persistSyncSnapshot(subscription);
|
|
836
960
|
// Every emitted ready frame is self-describing: when a legacy runtime
|
|
@@ -838,9 +962,53 @@ export class GonvexClient {
|
|
|
838
962
|
// observe one contract regardless of the peer's protocol generation.
|
|
839
963
|
this.emitSyncMessage(subscription, message.digest === verifiedDigest ? message : { ...message, digest: verifiedDigest });
|
|
840
964
|
}
|
|
965
|
+
handleSyncWatermark(revision) {
|
|
966
|
+
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
967
|
+
return;
|
|
968
|
+
for (const subscription of this.syncSubscriptions.values()) {
|
|
969
|
+
const cursor = subscription.cursor;
|
|
970
|
+
if (!cursor
|
|
971
|
+
|| cursor.revision >= revision
|
|
972
|
+
|| !subscription.isUpToDate
|
|
973
|
+
|| subscription.opening
|
|
974
|
+
|| subscription.forceFullIntegrity
|
|
975
|
+
|| subscription.integrityRows !== subscription.rows
|
|
976
|
+
|| !subscription.integrityDigest
|
|
977
|
+
|| subscription.integrityEpoch !== cursor.epoch)
|
|
978
|
+
continue;
|
|
979
|
+
subscription.cursor = { ...cursor, revision };
|
|
980
|
+
this.scheduleSyncWatermarkPersistence(subscription);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
scheduleSyncWatermarkPersistence(subscription) {
|
|
984
|
+
if (subscription.watermarkPersistTimer)
|
|
985
|
+
return;
|
|
986
|
+
subscription.watermarkPersistTimer = setTimeout(() => {
|
|
987
|
+
subscription.watermarkPersistTimer = undefined;
|
|
988
|
+
if (this.syncSubscriptions.get(subscription.key) !== subscription)
|
|
989
|
+
return;
|
|
990
|
+
this.persistSyncSnapshot(subscription, true);
|
|
991
|
+
}, syncWatermarkPersistDelayMs);
|
|
992
|
+
}
|
|
841
993
|
emitSyncMessage(subscription, message) {
|
|
994
|
+
const outgoing = this.materializeSyncMessage(subscription, message);
|
|
842
995
|
for (const listener of Array.from(subscription.listeners))
|
|
843
|
-
listener(
|
|
996
|
+
listener(outgoing);
|
|
997
|
+
}
|
|
998
|
+
materializeSyncMessage(subscription, message) {
|
|
999
|
+
if (message.type !== "sync.snapshot")
|
|
1000
|
+
return message;
|
|
1001
|
+
return {
|
|
1002
|
+
...message,
|
|
1003
|
+
result: this.overlay.apply(subscription.path, message.result, message.key),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
emitOptimisticCollection(collection) {
|
|
1007
|
+
for (const subscription of this.syncSubscriptions.values()) {
|
|
1008
|
+
if (subscription.path !== collection || subscription.lastMessage?.type !== "sync.snapshot")
|
|
1009
|
+
continue;
|
|
1010
|
+
this.emitSyncMessage(subscription, subscription.lastMessage);
|
|
1011
|
+
}
|
|
844
1012
|
}
|
|
845
1013
|
markSyncSubscriptionsOutOfDate() {
|
|
846
1014
|
for (const subscription of this.syncSubscriptions.values()) {
|
|
@@ -905,6 +1073,7 @@ export class GonvexClient {
|
|
|
905
1073
|
subscription.cursor = cached.cursor;
|
|
906
1074
|
subscription.keyField = cached.keyField;
|
|
907
1075
|
subscription.mode = cached.mode;
|
|
1076
|
+
subscription.truncated = cached.truncated;
|
|
908
1077
|
subscription.orderBy = cached.orderBy;
|
|
909
1078
|
subscription.orderDirection = cached.orderDirection;
|
|
910
1079
|
subscription.maxRows = cached.maxRows;
|
|
@@ -915,6 +1084,7 @@ export class GonvexClient {
|
|
|
915
1084
|
subscription.hashes = {};
|
|
916
1085
|
subscription.integrityDigest = undefined;
|
|
917
1086
|
subscription.integrityRows = undefined;
|
|
1087
|
+
subscription.integrityEpoch = undefined;
|
|
918
1088
|
const message = {
|
|
919
1089
|
type: "sync.snapshot",
|
|
920
1090
|
id: subscription.id,
|
|
@@ -958,6 +1128,7 @@ export class GonvexClient {
|
|
|
958
1128
|
subscription.hashes = hashes;
|
|
959
1129
|
subscription.integrityDigest = digest;
|
|
960
1130
|
subscription.integrityRows = rows;
|
|
1131
|
+
subscription.integrityEpoch = subscription.cursor?.epoch;
|
|
961
1132
|
subscription.opening = false;
|
|
962
1133
|
this.sendSyncOpen(subscription);
|
|
963
1134
|
}).catch(() => {
|
|
@@ -1039,7 +1210,11 @@ export class GonvexClient {
|
|
|
1039
1210
|
this.send({ type: "sync.close", id: latest.id });
|
|
1040
1211
|
}, this.syncSubscriptionRetentionMs);
|
|
1041
1212
|
}
|
|
1042
|
-
persistSyncSnapshot(subscription) {
|
|
1213
|
+
persistSyncSnapshot(subscription, fromWatermark = false) {
|
|
1214
|
+
if (!fromWatermark && subscription.watermarkPersistTimer) {
|
|
1215
|
+
clearTimeout(subscription.watermarkPersistTimer);
|
|
1216
|
+
subscription.watermarkPersistTimer = undefined;
|
|
1217
|
+
}
|
|
1043
1218
|
const directive = this.queryCacheDirective;
|
|
1044
1219
|
const store = this.syncStore;
|
|
1045
1220
|
if (!directive || !store || !subscription.cursor)
|
|
@@ -1054,6 +1229,7 @@ export class GonvexClient {
|
|
|
1054
1229
|
cursor: subscription.cursor,
|
|
1055
1230
|
keyField: subscription.keyField,
|
|
1056
1231
|
mode: subscription.mode,
|
|
1232
|
+
truncated: subscription.truncated,
|
|
1057
1233
|
orderBy: subscription.orderBy,
|
|
1058
1234
|
orderDirection: subscription.orderDirection,
|
|
1059
1235
|
maxRows: subscription.maxRows,
|
|
@@ -1065,6 +1241,10 @@ export class GonvexClient {
|
|
|
1065
1241
|
this.enqueueSyncPersistence(subscription, scope, () => store.replace(scope, subscription.path, subscription.args, value));
|
|
1066
1242
|
}
|
|
1067
1243
|
persistSyncDelta(subscription, upserts, deleted) {
|
|
1244
|
+
if (subscription.watermarkPersistTimer) {
|
|
1245
|
+
clearTimeout(subscription.watermarkPersistTimer);
|
|
1246
|
+
subscription.watermarkPersistTimer = undefined;
|
|
1247
|
+
}
|
|
1068
1248
|
const directive = this.queryCacheDirective;
|
|
1069
1249
|
const store = this.syncStore;
|
|
1070
1250
|
if (!directive || !store || !subscription.cursor)
|
|
@@ -1074,6 +1254,7 @@ export class GonvexClient {
|
|
|
1074
1254
|
cursor: subscription.cursor,
|
|
1075
1255
|
keyField: subscription.keyField,
|
|
1076
1256
|
mode: subscription.mode,
|
|
1257
|
+
truncated: subscription.truncated,
|
|
1077
1258
|
orderBy: subscription.orderBy,
|
|
1078
1259
|
orderDirection: subscription.orderDirection,
|
|
1079
1260
|
upserts,
|
|
@@ -1087,8 +1268,100 @@ export class GonvexClient {
|
|
|
1087
1268
|
subscription.persistedRows = subscription.rows;
|
|
1088
1269
|
this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
|
|
1089
1270
|
}
|
|
1271
|
+
async restoreOutbox() {
|
|
1272
|
+
const entries = await this.mutationOutbox.loadAll();
|
|
1273
|
+
if (this.manuallyClosed)
|
|
1274
|
+
return;
|
|
1275
|
+
for (const entry of entries) {
|
|
1276
|
+
this.addOptimisticMutation(entry.idempotencyKey, entry.patches ?? []);
|
|
1277
|
+
}
|
|
1278
|
+
const nextAttemptAt = Math.min(...entries
|
|
1279
|
+
.filter((entry) => entry.state === "pending")
|
|
1280
|
+
.map((entry) => entry.nextAttemptAt));
|
|
1281
|
+
if (Number.isFinite(nextAttemptAt) && nextAttemptAt > Date.now()) {
|
|
1282
|
+
this.scheduleOutboxDrain(nextAttemptAt - Date.now());
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
addOptimisticMutation(mutationId, patches) {
|
|
1286
|
+
if (patches.length === 0 || this.optimisticMutationIds.has(mutationId))
|
|
1287
|
+
return;
|
|
1288
|
+
this.optimisticMutationIds.add(mutationId);
|
|
1289
|
+
this.overlay.add(mutationId, patches);
|
|
1290
|
+
}
|
|
1291
|
+
settleOptimisticMutation(mutationId) {
|
|
1292
|
+
this.optimisticMutationIds.delete(mutationId);
|
|
1293
|
+
this.overlay.settle(mutationId);
|
|
1294
|
+
}
|
|
1295
|
+
rejectOptimisticMutation(mutationId) {
|
|
1296
|
+
this.optimisticMutationIds.delete(mutationId);
|
|
1297
|
+
this.overlay.reject(mutationId);
|
|
1298
|
+
}
|
|
1299
|
+
async drainOutbox() {
|
|
1300
|
+
await this.outboxReady;
|
|
1301
|
+
if (this.drainingOutbox
|
|
1302
|
+
|| this.manuallyClosed
|
|
1303
|
+
|| !this.socket
|
|
1304
|
+
|| this.socket.readyState !== WebSocket.OPEN)
|
|
1305
|
+
return;
|
|
1306
|
+
this.drainingOutbox = true;
|
|
1307
|
+
try {
|
|
1308
|
+
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
1309
|
+
const entry = await this.mutationOutbox.nextReady(Date.now());
|
|
1310
|
+
if (!entry)
|
|
1311
|
+
return;
|
|
1312
|
+
await this.mutationOutbox.markInflight(entry.id);
|
|
1313
|
+
try {
|
|
1314
|
+
await this.call("mutation", { kind: "mutation", path: entry.path }, entry.args, this.timeouts.mutationTimeoutMs, entry.idempotencyKey);
|
|
1315
|
+
await this.mutationOutbox.ack(entry.id);
|
|
1316
|
+
this.settleOptimisticMutation(entry.idempotencyKey);
|
|
1317
|
+
}
|
|
1318
|
+
catch (error) {
|
|
1319
|
+
if (error instanceof GonvexClientError && error.code === "server") {
|
|
1320
|
+
await this.mutationOutbox.ack(entry.id);
|
|
1321
|
+
this.rejectOptimisticMutation(entry.idempotencyKey);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
await this.mutationOutbox.fail(entry.id, mutationErrorMessage(error));
|
|
1325
|
+
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
finally {
|
|
1331
|
+
this.drainingOutbox = false;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
scheduleOutboxDrain(delay) {
|
|
1335
|
+
if (this.manuallyClosed)
|
|
1336
|
+
return;
|
|
1337
|
+
if (this.outboxDrainTimer)
|
|
1338
|
+
clearTimeout(this.outboxDrainTimer);
|
|
1339
|
+
this.outboxDrainTimer = setTimeout(() => {
|
|
1340
|
+
this.outboxDrainTimer = undefined;
|
|
1341
|
+
void this.drainOutbox();
|
|
1342
|
+
}, delay);
|
|
1343
|
+
}
|
|
1090
1344
|
mutation(ref, args = {}, options = {}) {
|
|
1091
|
-
|
|
1345
|
+
const mutationId = randomID();
|
|
1346
|
+
const patches = options.optimistic ?? [];
|
|
1347
|
+
this.addOptimisticMutation(mutationId, patches);
|
|
1348
|
+
return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId).then((result) => {
|
|
1349
|
+
this.settleOptimisticMutation(mutationId);
|
|
1350
|
+
return result;
|
|
1351
|
+
}).catch(async (error) => {
|
|
1352
|
+
if (isQueueableMutationError(error) && options.offline === "queue") {
|
|
1353
|
+
await this.mutationOutbox.enqueue({
|
|
1354
|
+
path: ref.path,
|
|
1355
|
+
args,
|
|
1356
|
+
idempotencyKey: mutationId,
|
|
1357
|
+
entityKeys: patches.map((patch) => patch.rowId),
|
|
1358
|
+
patches,
|
|
1359
|
+
});
|
|
1360
|
+
return { status: "queued", mutationId };
|
|
1361
|
+
}
|
|
1362
|
+
this.rejectOptimisticMutation(mutationId);
|
|
1363
|
+
throw error;
|
|
1364
|
+
});
|
|
1092
1365
|
}
|
|
1093
1366
|
action(ref, args = {}, options = {}) {
|
|
1094
1367
|
return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
|
|
@@ -1206,9 +1479,9 @@ export class GonvexClient {
|
|
|
1206
1479
|
this.notifyConnectionState();
|
|
1207
1480
|
return Promise.all(registered.map((entry) => settle(entry.promise, entry.path)));
|
|
1208
1481
|
}
|
|
1209
|
-
call(kind, ref, args, timeoutMs) {
|
|
1482
|
+
call(kind, ref, args, timeoutMs, id) {
|
|
1210
1483
|
this.connect();
|
|
1211
|
-
const entry = this.registerCall(kind, ref, args, timeoutMs);
|
|
1484
|
+
const entry = this.registerCall(kind, ref, args, timeoutMs, id);
|
|
1212
1485
|
if (kind === "mutation") {
|
|
1213
1486
|
try {
|
|
1214
1487
|
const w = globalThis;
|
|
@@ -1224,8 +1497,8 @@ export class GonvexClient {
|
|
|
1224
1497
|
this.notifyConnectionState();
|
|
1225
1498
|
return entry.promise;
|
|
1226
1499
|
}
|
|
1227
|
-
registerCall(kind, ref, args, timeoutMs) {
|
|
1228
|
-
const id =
|
|
1500
|
+
registerCall(kind, ref, args, timeoutMs, callId = randomID()) {
|
|
1501
|
+
const id = callId;
|
|
1229
1502
|
const clientSentAtMs = nowMs();
|
|
1230
1503
|
const promise = new Promise((resolve, reject) => {
|
|
1231
1504
|
const pending = { id, kind, path: ref.path, reject };
|
|
@@ -1525,12 +1798,17 @@ export class GonvexClient {
|
|
|
1525
1798
|
this.syncScopeGeneration += 1;
|
|
1526
1799
|
for (const subscription of this.syncSubscriptions.values()) {
|
|
1527
1800
|
this.clearSyncRetry(subscription, true);
|
|
1801
|
+
if (subscription.watermarkPersistTimer) {
|
|
1802
|
+
clearTimeout(subscription.watermarkPersistTimer);
|
|
1803
|
+
subscription.watermarkPersistTimer = undefined;
|
|
1804
|
+
}
|
|
1528
1805
|
subscription.isUpToDate = false;
|
|
1529
1806
|
subscription.rows = [];
|
|
1530
1807
|
subscription.persistedRows = undefined;
|
|
1531
1808
|
subscription.hashes = {};
|
|
1532
1809
|
subscription.integrityDigest = undefined;
|
|
1533
1810
|
subscription.integrityRows = undefined;
|
|
1811
|
+
subscription.integrityEpoch = undefined;
|
|
1534
1812
|
subscription.forceFullIntegrity = false;
|
|
1535
1813
|
subscription.cursor = undefined;
|
|
1536
1814
|
subscription.lastMessage = undefined;
|
|
@@ -1675,11 +1953,20 @@ export class GonvexClient {
|
|
|
1675
1953
|
device: event.device ?? browserTelemetryInfo(),
|
|
1676
1954
|
});
|
|
1677
1955
|
}
|
|
1678
|
-
sendAuth(force) {
|
|
1679
|
-
if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project)
|
|
1956
|
+
sendAuth(force, options = {}) {
|
|
1957
|
+
if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project && !this.auth.fetchToken)
|
|
1680
1958
|
return;
|
|
1681
1959
|
this.authInFlight = true;
|
|
1960
|
+
this.authRetriedAfterError = false;
|
|
1682
1961
|
this.armAuthWatchdog();
|
|
1962
|
+
const fetcher = this.auth.fetchToken;
|
|
1963
|
+
if (fetcher && options.useFetcher !== false) {
|
|
1964
|
+
void this.fetchAndSendAuth(fetcher);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
this.sendAuthFrame();
|
|
1968
|
+
}
|
|
1969
|
+
sendAuthFrame() {
|
|
1683
1970
|
this.sendNow({
|
|
1684
1971
|
type: "auth",
|
|
1685
1972
|
id: randomID(),
|
|
@@ -1687,8 +1974,77 @@ export class GonvexClient {
|
|
|
1687
1974
|
project: this.auth.project,
|
|
1688
1975
|
tenant: this.auth.tenant,
|
|
1689
1976
|
device: browserTelemetryInfo(),
|
|
1977
|
+
capabilities: { syncReadyMany: 1, syncWatermark: 1 },
|
|
1690
1978
|
});
|
|
1691
1979
|
}
|
|
1980
|
+
// Tokens from a fetcher are typically short-lived while the socket (and any
|
|
1981
|
+
// disconnect gap) can span hours: replaying the token that was current at
|
|
1982
|
+
// setAuth time guarantees an auth.error after a long sleep. authInFlight is
|
|
1983
|
+
// already true here, so everything else queues behind the fetch exactly as
|
|
1984
|
+
// it queues behind the server's auth reply.
|
|
1985
|
+
async fetchAndSendAuth(fetcher) {
|
|
1986
|
+
const generation = ++this.authFetchGeneration;
|
|
1987
|
+
const socket = this.socket;
|
|
1988
|
+
let token;
|
|
1989
|
+
try {
|
|
1990
|
+
token = await fetcher({ forceRefreshToken: false });
|
|
1991
|
+
}
|
|
1992
|
+
catch {
|
|
1993
|
+
// A fetcher that cannot reach its identity provider (offline start)
|
|
1994
|
+
// must not sign the session out — fall back to the installed token.
|
|
1995
|
+
token = undefined;
|
|
1996
|
+
}
|
|
1997
|
+
if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
|
|
1998
|
+
return;
|
|
1999
|
+
if (typeof token === "string" && token) {
|
|
2000
|
+
this.applyAuth({ token });
|
|
2001
|
+
}
|
|
2002
|
+
else if (token === null) {
|
|
2003
|
+
// The fetcher is authoritative about sign-out.
|
|
2004
|
+
this.applyAuth({ token: undefined });
|
|
2005
|
+
}
|
|
2006
|
+
// A dead socket's close handler already reset authInFlight; the next
|
|
2007
|
+
// reconnect runs its own sendAuth, so this resolve has nothing to send.
|
|
2008
|
+
if (this.socket !== socket || socket?.readyState !== WebSocket.OPEN)
|
|
2009
|
+
return;
|
|
2010
|
+
this.sendAuthFrame();
|
|
2011
|
+
}
|
|
2012
|
+
async refreshRejectedAuth(fetcher, rejectedToken, error) {
|
|
2013
|
+
const generation = ++this.authFetchGeneration;
|
|
2014
|
+
const socket = this.socket;
|
|
2015
|
+
let token;
|
|
2016
|
+
try {
|
|
2017
|
+
token = await fetcher({ forceRefreshToken: true });
|
|
2018
|
+
}
|
|
2019
|
+
catch {
|
|
2020
|
+
token = undefined;
|
|
2021
|
+
}
|
|
2022
|
+
if (generation !== this.authFetchGeneration || this.auth.fetchToken !== fetcher)
|
|
2023
|
+
return;
|
|
2024
|
+
if (typeof token === "string" && token && token !== rejectedToken) {
|
|
2025
|
+
this.applyAuth({ token });
|
|
2026
|
+
if (this.socket === socket && socket?.readyState === WebSocket.OPEN) {
|
|
2027
|
+
this.sendAuthFrame();
|
|
2028
|
+
}
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
// No fresher credential exists (fetch failed, signed out, or the refresh
|
|
2032
|
+
// returned the very token the server just refused): surface the rejection
|
|
2033
|
+
// and degrade to the unauthenticated flow exactly like the no-fetcher path.
|
|
2034
|
+
this.authInFlight = false;
|
|
2035
|
+
if (this.authWatchdogTimer) {
|
|
2036
|
+
clearTimeout(this.authWatchdogTimer);
|
|
2037
|
+
this.authWatchdogTimer = undefined;
|
|
2038
|
+
}
|
|
2039
|
+
this.resetQueryCacheScope();
|
|
2040
|
+
this.notifyAuthError(error);
|
|
2041
|
+
this.flushPendingMessages();
|
|
2042
|
+
}
|
|
2043
|
+
notifyAuthError(error) {
|
|
2044
|
+
for (const handler of Array.from(this.authErrorHandlers)) {
|
|
2045
|
+
handler(error);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
1692
2048
|
// A lost auth reply (e.g. the server swapped its app plugin and dropped
|
|
1693
2049
|
// in-flight responses while the socket stayed up) used to leave
|
|
1694
2050
|
// authInFlight stuck true forever: every later mutation/subscription
|
|
@@ -1761,6 +2117,13 @@ function countPendingCalls(calls, kind) {
|
|
|
1761
2117
|
}
|
|
1762
2118
|
return count;
|
|
1763
2119
|
}
|
|
2120
|
+
function isQueueableMutationError(error) {
|
|
2121
|
+
return error instanceof GonvexClientError
|
|
2122
|
+
&& (error.code === "disconnected" || error.code === "timeout");
|
|
2123
|
+
}
|
|
2124
|
+
function mutationErrorMessage(error) {
|
|
2125
|
+
return error instanceof Error ? error.message : String(error);
|
|
2126
|
+
}
|
|
1764
2127
|
function stableStringify(value) {
|
|
1765
2128
|
if (typeof value === "string") {
|
|
1766
2129
|
return JSON.stringify(value)
|
|
@@ -1891,6 +2254,7 @@ function authFromOptions(options) {
|
|
|
1891
2254
|
tenant: options.tenant,
|
|
1892
2255
|
telemetry: options.telemetry,
|
|
1893
2256
|
identity: options.identity,
|
|
2257
|
+
fetchToken: options.fetchToken,
|
|
1894
2258
|
};
|
|
1895
2259
|
}
|
|
1896
2260
|
function normalizeQuerySubscriptionRetentionMs(value) {
|