@gonvex/client 0.5.1 → 0.5.2-staging.0
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 +26 -4
- package/dist/index.js +470 -96
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-replica.d.ts +1 -0
- package/dist/indexeddb-replica.js +47 -3
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +49 -0
- package/dist/local-replica.js +280 -13
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outbox.js +57 -0
- package/dist/outbox.js.map +1 -1
- package/dist/query-expression.d.ts +1 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { replicaHashesDigest, replicaRowsHashes
|
|
1
|
+
import { replicaHashesDigest, replicaRowsHashes } from "./replica-integrity.js";
|
|
2
2
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
3
3
|
export { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
4
|
import { optimisticPatchesFromReference, } from "./optimistic.js";
|
|
@@ -22,6 +22,18 @@ function asReplicaRow(value) {
|
|
|
22
22
|
? value
|
|
23
23
|
: undefined;
|
|
24
24
|
}
|
|
25
|
+
function projectReplicaIntegrityRows(rows, columns) {
|
|
26
|
+
if (!columns?.length)
|
|
27
|
+
return rows.map((row) => ({ ...row }));
|
|
28
|
+
return rows.map((row) => {
|
|
29
|
+
const projected = {};
|
|
30
|
+
for (const column of columns) {
|
|
31
|
+
if (hasOwn(row, column))
|
|
32
|
+
projected[column] = row[column];
|
|
33
|
+
}
|
|
34
|
+
return projected;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
25
37
|
function createLocalReplicaView(replica) {
|
|
26
38
|
return Object.freeze({
|
|
27
39
|
cursor: () => replica.cursor(),
|
|
@@ -70,6 +82,8 @@ function raiseReplicaCursorFloor(subscription, cursor) {
|
|
|
70
82
|
* Reducers/actions fail closed unless a reducer opted into the outbox.
|
|
71
83
|
* - `closed`: the client was explicitly closed.
|
|
72
84
|
* - `auth`: authentication was rejected.
|
|
85
|
+
* - `superseded`: the operation belonged to an authentication scope that the
|
|
86
|
+
* caller replaced before the operation completed.
|
|
73
87
|
*/
|
|
74
88
|
export class GonvexClientError extends Error {
|
|
75
89
|
code;
|
|
@@ -116,6 +130,12 @@ export class GonvexClient {
|
|
|
116
130
|
activeArtifactHashValue = "";
|
|
117
131
|
auth = {};
|
|
118
132
|
authInFlight = false;
|
|
133
|
+
// Only the newest auth frame may change socket authorization. The runtime
|
|
134
|
+
// can finish an earlier project-only frame after a newer tenant frame; its
|
|
135
|
+
// response is obsolete even though both travelled on the same WebSocket.
|
|
136
|
+
latestAuthFrameId;
|
|
137
|
+
activeAuthFrameId;
|
|
138
|
+
authResendRequired = false;
|
|
119
139
|
authWatchdogTimer;
|
|
120
140
|
// Monotonic guard for async token fetches: a resolve whose generation is no
|
|
121
141
|
// longer current was superseded (newer setAuth, watchdog re-issue, or a
|
|
@@ -133,6 +153,8 @@ export class GonvexClient {
|
|
|
133
153
|
replica;
|
|
134
154
|
replicaView;
|
|
135
155
|
optimisticReducerIds = new Set();
|
|
156
|
+
/** Reducers currently owned by the foreground send path, never by recovery. */
|
|
157
|
+
directOutboxReducerIds = new Set();
|
|
136
158
|
optimisticOutboxEntryIds = new Map();
|
|
137
159
|
outboxReady;
|
|
138
160
|
outboxScope = "";
|
|
@@ -141,7 +163,11 @@ export class GonvexClient {
|
|
|
141
163
|
replicaScope = "";
|
|
142
164
|
hasAuthoritativeReplicaScope = false;
|
|
143
165
|
replicaReady = Promise.resolve();
|
|
166
|
+
replicaFrames = Promise.resolve();
|
|
167
|
+
processedReplicaWatermarkRevision = 0;
|
|
168
|
+
pendingReplicaTransactions = [];
|
|
144
169
|
unsubscribeOutbox;
|
|
170
|
+
unsubscribeBrowserOnline;
|
|
145
171
|
drainingOutbox = false;
|
|
146
172
|
outboxDrainTimer;
|
|
147
173
|
sessionScopeHandlers = new Set();
|
|
@@ -149,6 +175,7 @@ export class GonvexClient {
|
|
|
149
175
|
reconnectTimer;
|
|
150
176
|
reconnectAttempt = 0;
|
|
151
177
|
socketGeneration = 0;
|
|
178
|
+
authenticatedSocketGeneration;
|
|
152
179
|
manuallyClosed = false;
|
|
153
180
|
pendingCalls = new Map();
|
|
154
181
|
connectionStateHandlers = new Set();
|
|
@@ -169,6 +196,16 @@ export class GonvexClient {
|
|
|
169
196
|
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
170
197
|
void this.drainOutbox();
|
|
171
198
|
});
|
|
199
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
200
|
+
const onBrowserOnline = () => {
|
|
201
|
+
if (this.manuallyClosed)
|
|
202
|
+
return;
|
|
203
|
+
this.connect();
|
|
204
|
+
void this.drainOutbox();
|
|
205
|
+
};
|
|
206
|
+
globalThis.addEventListener("online", onBrowserOnline);
|
|
207
|
+
this.unsubscribeBrowserOnline = () => globalThis.removeEventListener("online", onBrowserOnline);
|
|
208
|
+
}
|
|
172
209
|
// Select the initial identity scope synchronously so subscriptions created
|
|
173
210
|
// immediately after the client cannot capture an empty placeholder scope.
|
|
174
211
|
// A same-tick setAuth supersedes this activation by generation before its
|
|
@@ -263,12 +300,37 @@ export class GonvexClient {
|
|
|
263
300
|
}
|
|
264
301
|
}
|
|
265
302
|
setAuth(auth) {
|
|
303
|
+
const nextAuth = { ...this.auth, ...auth };
|
|
304
|
+
const changesAuthFrame = this.auth.project !== nextAuth.project
|
|
305
|
+
|| this.auth.tenant !== nextAuth.tenant
|
|
306
|
+
|| this.auth.token !== nextAuth.token
|
|
307
|
+
|| this.auth.identity?.sub !== nextAuth.identity?.sub
|
|
308
|
+
|| this.auth.identity?.iss !== nextAuth.identity?.iss;
|
|
309
|
+
const needsFetcherAuth = !nextAuth.token
|
|
310
|
+
&& nextAuth.fetchToken !== undefined
|
|
311
|
+
&& nextAuth.fetchToken !== this.auth.fetchToken;
|
|
312
|
+
if (!changesAuthFrame && !needsFetcherAuth) {
|
|
313
|
+
// Local auth metadata (most commonly React installing a refresh
|
|
314
|
+
// callback for an already installed token) does not require another
|
|
315
|
+
// wire auth frame. Duplicating it can race the response to the current
|
|
316
|
+
// frame and has no authorization effect.
|
|
317
|
+
this.applyAuth(auth);
|
|
318
|
+
this.authFetchGeneration += 1;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
266
321
|
this.cancelManagedAuthAttempt("Authentication was replaced by a newer session.");
|
|
267
322
|
this.applyAuth(auth);
|
|
268
323
|
// The caller owns auth now: a token fetch still in flight from the
|
|
269
324
|
// previous installation must not clobber this one when it resolves.
|
|
270
325
|
this.authFetchGeneration += 1;
|
|
271
326
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
327
|
+
if (this.authInFlight && this.latestAuthFrameId) {
|
|
328
|
+
// Keep server-side connection auth transitions serialized. Sending a
|
|
329
|
+
// second frame concurrently allows an older, slower project-only auth
|
|
330
|
+
// operation to overwrite a newer accepted tenant session.
|
|
331
|
+
this.authResendRequired = true;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
272
334
|
// A token supplied in this very call was just minted by the caller —
|
|
273
335
|
// send it as-is instead of paying another fetch round trip.
|
|
274
336
|
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
@@ -288,7 +350,12 @@ export class GonvexClient {
|
|
|
288
350
|
this.managedAuthAttempt = { ids: new Set(), resolve, reject };
|
|
289
351
|
});
|
|
290
352
|
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
291
|
-
this.
|
|
353
|
+
if (this.authInFlight && this.latestAuthFrameId) {
|
|
354
|
+
this.authResendRequired = true;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
358
|
+
}
|
|
292
359
|
}
|
|
293
360
|
else {
|
|
294
361
|
this.connect();
|
|
@@ -321,20 +388,24 @@ export class GonvexClient {
|
|
|
321
388
|
|| (hasOwn(auth, "identity") && !sameAuthTokenIdentity(this.auth, nextAuth));
|
|
322
389
|
if (scopeMayChange) {
|
|
323
390
|
this.pendingMessages.length = 0;
|
|
324
|
-
this.
|
|
391
|
+
this.pendingReplicaTransactions.length = 0;
|
|
392
|
+
this.rejectPendingCalls((call) => new GonvexClientError(`Authentication scope changed while waiting for ${call.kind} ${call.path}`, { code: "superseded", path: call.path, operation: call.kind }));
|
|
325
393
|
for (const query of this.oneShotQueries.values()) {
|
|
326
394
|
if (query.timeoutTimer)
|
|
327
395
|
clearTimeout(query.timeoutTimer);
|
|
328
396
|
this.handlers.delete(query.id);
|
|
329
|
-
query.reject(new GonvexClientError(`Authentication scope changed while waiting for Query ${query.path}`, { code: "
|
|
397
|
+
query.reject(new GonvexClientError(`Authentication scope changed while waiting for Query ${query.path}`, { code: "superseded", path: query.path, operation: "query" }));
|
|
330
398
|
}
|
|
331
399
|
this.oneShotQueries.clear();
|
|
332
|
-
|
|
400
|
+
// Authentication is changing the project, tenant, or actor. Immediately
|
|
401
|
+
// leave the previous authoritative Replica scope so hooks mounted by the
|
|
402
|
+
// next React render cannot send tenant reads against the old server
|
|
403
|
+
// session. The accepted auth result installs the next scope below.
|
|
404
|
+
this.quarantineReplicaScope();
|
|
333
405
|
}
|
|
334
406
|
this.auth = nextAuth;
|
|
335
407
|
if (scopeMayChange) {
|
|
336
408
|
void this.activateOutboxScope();
|
|
337
|
-
this.rotateSubscriptionScopes();
|
|
338
409
|
}
|
|
339
410
|
if (auth.tenant !== undefined)
|
|
340
411
|
this.errorReporter?.setTenant(auth.tenant);
|
|
@@ -378,6 +449,9 @@ export class GonvexClient {
|
|
|
378
449
|
this.replica.setFreshness("offline");
|
|
379
450
|
this.markReplicaSubscriptionsOutOfDate();
|
|
380
451
|
this.authInFlight = false;
|
|
452
|
+
this.authResendRequired = false;
|
|
453
|
+
this.latestAuthFrameId = undefined;
|
|
454
|
+
this.activeAuthFrameId = undefined;
|
|
381
455
|
if (this.authWatchdogTimer) {
|
|
382
456
|
clearTimeout(this.authWatchdogTimer);
|
|
383
457
|
this.authWatchdogTimer = undefined;
|
|
@@ -387,6 +461,7 @@ export class GonvexClient {
|
|
|
387
461
|
// drop them too — flushing them after reconnect would fire writes whose
|
|
388
462
|
// callers already saw a rejection.
|
|
389
463
|
this.pendingMessages.length = 0;
|
|
464
|
+
this.pendingReplicaTransactions.length = 0;
|
|
390
465
|
// Reducers/actions must fail closed on transport loss: silently
|
|
391
466
|
// replaying a non-idempotent write after reconnect is unsafe, and
|
|
392
467
|
// leaving the promise pending hangs the caller forever.
|
|
@@ -433,17 +508,82 @@ export class GonvexClient {
|
|
|
433
508
|
if (typeof message.artifactHash === "string") {
|
|
434
509
|
this.activeArtifactHashValue = message.artifactHash;
|
|
435
510
|
}
|
|
436
|
-
this.resumeQuerySubscriptions();
|
|
511
|
+
this.resumeQuerySubscriptions(true);
|
|
437
512
|
this.resumeReplicaSubscriptions();
|
|
438
513
|
return;
|
|
439
514
|
}
|
|
440
515
|
if (message.type === "auth.result" || message.type === "auth.error") {
|
|
441
|
-
|
|
516
|
+
if (message.type === "auth.error"
|
|
517
|
+
&& (message.id === "membership-changed" || message.id === "session-expired")) {
|
|
518
|
+
// These are unsolicited connection-scope events, not replies to an
|
|
519
|
+
// auth frame. The runtime has already discarded every tenant
|
|
520
|
+
// subscription. Rotate their routing IDs immediately so delayed
|
|
521
|
+
// Replica errors from that discarded scope cannot reach current
|
|
522
|
+
// React listeners.
|
|
523
|
+
this.pendingReplicaTransactions.length = 0;
|
|
524
|
+
this.quarantineReplicaScope();
|
|
525
|
+
this.activeAuthFrameId = undefined;
|
|
526
|
+
// If credentials are already being revalidated, that queued auth
|
|
527
|
+
// frame will install the new authoritative scope. Starting another
|
|
528
|
+
// transition here would recreate the out-of-order auth race.
|
|
529
|
+
if (this.latestAuthFrameId)
|
|
530
|
+
return;
|
|
531
|
+
if (this.authWatchdogTimer) {
|
|
532
|
+
clearTimeout(this.authWatchdogTimer);
|
|
533
|
+
this.authWatchdogTimer = undefined;
|
|
534
|
+
}
|
|
535
|
+
this.authInFlight = false;
|
|
536
|
+
if (message.id === "membership-changed") {
|
|
537
|
+
this.sendAuth(true, { useFetcher: false });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const fetcher = this.auth.fetchToken;
|
|
541
|
+
if (fetcher) {
|
|
542
|
+
this.authInFlight = true;
|
|
543
|
+
this.authRetriedAfterError = true;
|
|
544
|
+
this.armAuthWatchdog();
|
|
545
|
+
void this.refreshRejectedAuth(fetcher, this.auth.token, message.error);
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
this.notifyAuthError(message.error);
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Auth work may complete out of order inside the runtime. An obsolete
|
|
553
|
+
// response must never settle the current transition, reopen queries,
|
|
554
|
+
// or downgrade an accepted tenant session to an older control-only
|
|
555
|
+
// scope.
|
|
556
|
+
const settlesLatestFrame = message.id === this.latestAuthFrameId;
|
|
557
|
+
const rejectsActiveFrame = message.type === "auth.error"
|
|
558
|
+
&& this.latestAuthFrameId === undefined
|
|
559
|
+
&& message.id === this.activeAuthFrameId;
|
|
560
|
+
if (!settlesLatestFrame && !rejectsActiveFrame)
|
|
561
|
+
return;
|
|
562
|
+
if (settlesLatestFrame)
|
|
563
|
+
this.latestAuthFrameId = undefined;
|
|
564
|
+
if (settlesLatestFrame && this.authResendRequired) {
|
|
565
|
+
// The settled frame represented credentials that have already been
|
|
566
|
+
// superseded locally. Do not publish or flush anything from that
|
|
567
|
+
// intermediate server scope; authenticate the newest state now.
|
|
568
|
+
this.authResendRequired = false;
|
|
569
|
+
if (this.authWatchdogTimer) {
|
|
570
|
+
clearTimeout(this.authWatchdogTimer);
|
|
571
|
+
this.authWatchdogTimer = undefined;
|
|
572
|
+
}
|
|
573
|
+
this.sendAuth(true);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (message.type === "auth.result")
|
|
577
|
+
this.activeAuthFrameId = message.id;
|
|
578
|
+
else
|
|
579
|
+
this.activeAuthFrameId = undefined;
|
|
442
580
|
if (this.authWatchdogTimer) {
|
|
443
581
|
clearTimeout(this.authWatchdogTimer);
|
|
444
582
|
this.authWatchdogTimer = undefined;
|
|
445
583
|
}
|
|
446
584
|
if (message.type === "auth.result") {
|
|
585
|
+
const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
|
|
586
|
+
this.authenticatedSocketGeneration = this.socketGeneration;
|
|
447
587
|
this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
|
|
448
588
|
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
449
589
|
if (developerSessionToken) {
|
|
@@ -454,30 +594,54 @@ export class GonvexClient {
|
|
|
454
594
|
this.authRetriedAfterError = false;
|
|
455
595
|
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
456
596
|
if (!directive) {
|
|
597
|
+
this.authInFlight = false;
|
|
457
598
|
if (!this.auth.tenant) {
|
|
458
|
-
this.resumeQuerySubscriptions();
|
|
599
|
+
this.resumeQuerySubscriptions(reauthenticatedSameSocket);
|
|
459
600
|
this.settleManagedAuthAttempt(message.id);
|
|
460
601
|
}
|
|
461
602
|
else {
|
|
462
603
|
this.settleManagedAuthAttempt(message.id, "Runtime did not provide an authoritative Local Replica visibility scope");
|
|
463
604
|
this.rejectMissingReplicaDirective();
|
|
464
605
|
}
|
|
606
|
+
// A reducer admitted while this auth frame was in flight may have
|
|
607
|
+
// been durably queued because the socket was not yet usable. Wake
|
|
608
|
+
// that queue once the successful auth result has made it usable.
|
|
609
|
+
void this.drainOutbox();
|
|
465
610
|
this.flushPendingMessages();
|
|
466
611
|
return;
|
|
467
612
|
}
|
|
468
613
|
void this.activateReplicaDirective(directive)
|
|
469
614
|
.then(() => this.activateOutboxScope())
|
|
470
615
|
.then(() => {
|
|
471
|
-
|
|
616
|
+
// The accepted server identity is not usable until its durable
|
|
617
|
+
// Replica partition has been activated locally. Only now may
|
|
618
|
+
// tenant calls and subscriptions leave the client.
|
|
619
|
+
this.authInFlight = false;
|
|
620
|
+
this.drainPendingReplicaTransactions();
|
|
621
|
+
// Authentication is replaced in-place on the same socket during
|
|
622
|
+
// normal token rotation. The runtime clears its subscription
|
|
623
|
+
// maps for every accepted auth frame, so same-generation Live
|
|
624
|
+
// Queries must be sent again just like Replica Collections.
|
|
625
|
+
this.resumeQuerySubscriptions(reauthenticatedSameSocket);
|
|
472
626
|
this.resumeReplicaSubscriptions();
|
|
473
627
|
this.settleManagedAuthAttempt(message.id);
|
|
628
|
+
// Same-scope reauthentication does not reload the outbox, so no
|
|
629
|
+
// restore callback will wake a reducer queued during auth.
|
|
630
|
+
void this.drainOutbox();
|
|
631
|
+
this.flushPendingMessages();
|
|
474
632
|
})
|
|
475
633
|
.catch((error) => {
|
|
634
|
+
this.authInFlight = false;
|
|
635
|
+
this.pendingReplicaTransactions.length = 0;
|
|
476
636
|
this.settleManagedAuthAttempt(message.id, error instanceof Error ? error.message : "Runtime returned an invalid Local Replica scope");
|
|
477
637
|
this.rejectReplicaDirective(error);
|
|
638
|
+
this.flushPendingMessages();
|
|
478
639
|
});
|
|
640
|
+
return;
|
|
479
641
|
}
|
|
480
642
|
else {
|
|
643
|
+
this.authInFlight = false;
|
|
644
|
+
this.pendingReplicaTransactions.length = 0;
|
|
481
645
|
const fetcher = this.auth.fetchToken;
|
|
482
646
|
if (fetcher && !this.authRetriedAfterError) {
|
|
483
647
|
// The installed token was rejected — typically expired while the
|
|
@@ -506,28 +670,20 @@ export class GonvexClient {
|
|
|
506
670
|
if (message.type === "replica.transaction") {
|
|
507
671
|
// Replica frames carry no tenant/scope field. During auth renewal we
|
|
508
672
|
// cannot safely attribute a late frame to either side of the switch.
|
|
509
|
-
if (this.authInFlight)
|
|
673
|
+
if (this.authInFlight) {
|
|
674
|
+
this.pendingReplicaTransactions.push(message);
|
|
510
675
|
return;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
cursor: message.cursor,
|
|
514
|
-
originCommandId: message.originCommandId,
|
|
515
|
-
provenance: message.provenance,
|
|
516
|
-
changes: message.changes.map((change) => ({
|
|
517
|
-
...change,
|
|
518
|
-
oldValue: asReplicaRow(change.oldValue),
|
|
519
|
-
newValue: asReplicaRow(change.newValue),
|
|
520
|
-
})),
|
|
521
|
-
}, scope).then(() => {
|
|
522
|
-
if (message.originCommandId && !this.replica.hasPendingCommand(message.originCommandId)) {
|
|
523
|
-
void this.ackOptimisticReducer(message.originCommandId);
|
|
524
|
-
}
|
|
525
|
-
}).catch(() => this.replica.setFreshness("verifying"));
|
|
676
|
+
}
|
|
677
|
+
this.enqueueReplicaTransaction(message);
|
|
526
678
|
return;
|
|
527
679
|
}
|
|
528
680
|
if (message.type === "replica.watermark") {
|
|
529
681
|
if (this.serverCapabilities.replicaWatermark === 1) {
|
|
530
|
-
|
|
682
|
+
// The runtime emits transactions and watermarks in one ordered
|
|
683
|
+
// stream. Keep both on the same client queue so a watermark cannot
|
|
684
|
+
// advance the cursor past a transaction that was received first but
|
|
685
|
+
// has not finished applying to durable Local Replica storage yet.
|
|
686
|
+
this.enqueueReplicaFrame(() => this.handleReplicaWatermark(message.revision));
|
|
531
687
|
}
|
|
532
688
|
return;
|
|
533
689
|
}
|
|
@@ -604,6 +760,7 @@ export class GonvexClient {
|
|
|
604
760
|
this.outboxDrainTimer = undefined;
|
|
605
761
|
}
|
|
606
762
|
this.unsubscribeOutbox();
|
|
763
|
+
this.unsubscribeBrowserOnline?.();
|
|
607
764
|
this.handlers.clear();
|
|
608
765
|
this.querySubscriptions.clear();
|
|
609
766
|
this.replicaSubscriptions.clear();
|
|
@@ -728,7 +885,7 @@ export class GonvexClient {
|
|
|
728
885
|
notify();
|
|
729
886
|
}
|
|
730
887
|
else if (message.type === "query.error") {
|
|
731
|
-
error = new GonvexClientError(message.error
|
|
888
|
+
error = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, { code: "server", path: ref.path, operation: "query" });
|
|
732
889
|
version += 1;
|
|
733
890
|
snapshot = { result, version };
|
|
734
891
|
notify();
|
|
@@ -976,7 +1133,8 @@ export class GonvexClient {
|
|
|
976
1133
|
id: randomID(),
|
|
977
1134
|
key,
|
|
978
1135
|
path: ref.path,
|
|
979
|
-
entity: ref.live?.entity ?? ref.path,
|
|
1136
|
+
entity: ref.replica?.table ?? ref.live?.entity ?? ref.path,
|
|
1137
|
+
columns: ref.replica?.columns,
|
|
980
1138
|
args,
|
|
981
1139
|
listeners: new Set([onMessage]),
|
|
982
1140
|
scope: this.replicaScope,
|
|
@@ -987,11 +1145,13 @@ export class GonvexClient {
|
|
|
987
1145
|
verificationGeneration: 0,
|
|
988
1146
|
retiredEpochs: new Set(),
|
|
989
1147
|
};
|
|
1148
|
+
if (ref.replica) {
|
|
1149
|
+
this.replica.registerReplicaCollection(key, ref.replica, asReplicaRow(args) ?? {});
|
|
1150
|
+
}
|
|
990
1151
|
this.replicaSubscriptions.set(key, subscription);
|
|
991
1152
|
this.handlers.set(subscription.id, (message) => {
|
|
992
1153
|
const scope = subscription.scope ?? this.replicaScope;
|
|
993
|
-
|
|
994
|
-
.catch(() => this.replica.setFreshness("verifying"));
|
|
1154
|
+
this.enqueueReplicaFrame(() => this.handleReplicaMessage(subscription, message, scope));
|
|
995
1155
|
});
|
|
996
1156
|
this.startReplica(subscription);
|
|
997
1157
|
return () => this.unsubscribeReplicaListener(key, onMessage);
|
|
@@ -1008,6 +1168,8 @@ export class GonvexClient {
|
|
|
1008
1168
|
let snapshotVersion = -1;
|
|
1009
1169
|
let snapshotRows;
|
|
1010
1170
|
let stateVersion = -1;
|
|
1171
|
+
let stateFreshness;
|
|
1172
|
+
let stateIsUpToDate;
|
|
1011
1173
|
let snapshotState;
|
|
1012
1174
|
let releaseTimer;
|
|
1013
1175
|
const notify = () => {
|
|
@@ -1025,13 +1187,23 @@ export class GonvexClient {
|
|
|
1025
1187
|
latestError = undefined;
|
|
1026
1188
|
notify();
|
|
1027
1189
|
}
|
|
1028
|
-
else if (message.type === "replica.
|
|
1190
|
+
else if (message.type === "replica.ready") {
|
|
1191
|
+
latestError = undefined;
|
|
1192
|
+
notify();
|
|
1193
|
+
}
|
|
1194
|
+
else if (message.type === "replica.snapshot") {
|
|
1029
1195
|
latestError = undefined;
|
|
1030
1196
|
}
|
|
1031
1197
|
});
|
|
1032
1198
|
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
1033
1199
|
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1034
1200
|
latestError = undefined;
|
|
1201
|
+
snapshotVersion = -1;
|
|
1202
|
+
snapshotRows = undefined;
|
|
1203
|
+
stateVersion = -1;
|
|
1204
|
+
stateFreshness = undefined;
|
|
1205
|
+
stateIsUpToDate = undefined;
|
|
1206
|
+
snapshotState = undefined;
|
|
1035
1207
|
notify();
|
|
1036
1208
|
});
|
|
1037
1209
|
return {
|
|
@@ -1040,7 +1212,7 @@ export class GonvexClient {
|
|
|
1040
1212
|
throw latestError;
|
|
1041
1213
|
if (!this.replica.hasLiveQuery(key))
|
|
1042
1214
|
return undefined;
|
|
1043
|
-
const version = this.replica.
|
|
1215
|
+
const version = this.replica.windowVersion(key);
|
|
1044
1216
|
if (snapshotVersion === version)
|
|
1045
1217
|
return snapshotRows;
|
|
1046
1218
|
snapshotVersion = version;
|
|
@@ -1052,11 +1224,25 @@ export class GonvexClient {
|
|
|
1052
1224
|
throw latestError;
|
|
1053
1225
|
if (!this.replica.hasLiveQuery(key))
|
|
1054
1226
|
return undefined;
|
|
1055
|
-
const version = this.replica.
|
|
1056
|
-
|
|
1227
|
+
const version = this.replica.windowVersion(key);
|
|
1228
|
+
const freshness = this.replica.freshness();
|
|
1229
|
+
const isUpToDate = this.replicaSubscriptions.get(key)?.isUpToDate === true;
|
|
1230
|
+
if (stateVersion === version
|
|
1231
|
+
&& stateFreshness === freshness
|
|
1232
|
+
&& stateIsUpToDate === isUpToDate)
|
|
1057
1233
|
return snapshotState;
|
|
1058
1234
|
stateVersion = version;
|
|
1059
|
-
|
|
1235
|
+
stateFreshness = freshness;
|
|
1236
|
+
stateIsUpToDate = isUpToDate;
|
|
1237
|
+
const state = this.replica.collectionState(key);
|
|
1238
|
+
snapshotState = {
|
|
1239
|
+
...state,
|
|
1240
|
+
isUpToDate,
|
|
1241
|
+
source: isUpToDate ? state.source : "cache",
|
|
1242
|
+
freshness: isUpToDate
|
|
1243
|
+
? state.freshness
|
|
1244
|
+
: state.freshness === "offline" ? "offline" : "verifying",
|
|
1245
|
+
};
|
|
1060
1246
|
return snapshotState;
|
|
1061
1247
|
},
|
|
1062
1248
|
status: () => ({
|
|
@@ -1124,7 +1310,7 @@ export class GonvexClient {
|
|
|
1124
1310
|
notify();
|
|
1125
1311
|
}
|
|
1126
1312
|
else if (message.type === "query.error") {
|
|
1127
|
-
latestError = new GonvexClientError(message.error
|
|
1313
|
+
latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
|
|
1128
1314
|
code: "server", path: ref.path, operation: "query",
|
|
1129
1315
|
});
|
|
1130
1316
|
notify();
|
|
@@ -1218,15 +1404,21 @@ export class GonvexClient {
|
|
|
1218
1404
|
subscription.isUpToDate = false;
|
|
1219
1405
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1220
1406
|
const rows = boundReplicaRows(message.result, message.key, message.maxRows, message.maxBytes, message.orderBy, message.orderDirection);
|
|
1407
|
+
let hashes;
|
|
1408
|
+
if (message.hashes && message.digest) {
|
|
1409
|
+
const digest = await replicaHashesDigest(message.hashes);
|
|
1410
|
+
if (digest === message.digest)
|
|
1411
|
+
hashes = message.hashes;
|
|
1412
|
+
}
|
|
1221
1413
|
const window = {
|
|
1222
1414
|
signature: subscription.key,
|
|
1223
1415
|
kind: "replica",
|
|
1224
1416
|
entity: subscription.entity,
|
|
1225
1417
|
key: message.key,
|
|
1226
1418
|
rows: rows.filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
completeness: "partial",
|
|
1419
|
+
// New runtimes include authoritative integrity and truncation metadata
|
|
1420
|
+
// in bounded snapshots. Older runtimes remain verifying until ready.
|
|
1421
|
+
completeness: hashes && message.truncated !== true ? "complete" : "partial",
|
|
1230
1422
|
source: "server",
|
|
1231
1423
|
cursor: message.cursor,
|
|
1232
1424
|
mode: message.mode,
|
|
@@ -1234,7 +1426,8 @@ export class GonvexClient {
|
|
|
1234
1426
|
orderDirection: message.orderDirection,
|
|
1235
1427
|
maxRows: message.maxRows,
|
|
1236
1428
|
maxBytes: message.maxBytes,
|
|
1237
|
-
|
|
1429
|
+
truncated: message.truncated,
|
|
1430
|
+
hashes,
|
|
1238
1431
|
scope,
|
|
1239
1432
|
};
|
|
1240
1433
|
await this.replica.replaceWindow(window);
|
|
@@ -1248,7 +1441,6 @@ export class GonvexClient {
|
|
|
1248
1441
|
if (replicaCursorIsStale(subscription, message.cursor) || (prior?.cursor && message.cursor.revision < prior.cursor.revision))
|
|
1249
1442
|
return;
|
|
1250
1443
|
this.clearReplicaRetry(subscription, true);
|
|
1251
|
-
subscription.isUpToDate = false;
|
|
1252
1444
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1253
1445
|
await this.replica.applyWindowDelta({
|
|
1254
1446
|
signature: subscription.key,
|
|
@@ -1266,7 +1458,9 @@ export class GonvexClient {
|
|
|
1266
1458
|
orderDirection: prior?.orderDirection,
|
|
1267
1459
|
maxRows: prior?.maxRows,
|
|
1268
1460
|
maxBytes: prior?.maxBytes,
|
|
1269
|
-
|
|
1461
|
+
// A delta invalidates the prior full integrity map unless the server
|
|
1462
|
+
// supplied a complete replacement map with this frame.
|
|
1463
|
+
hashes: message.hashes,
|
|
1270
1464
|
});
|
|
1271
1465
|
const snapshot = {
|
|
1272
1466
|
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
@@ -1307,14 +1501,14 @@ export class GonvexClient {
|
|
|
1307
1501
|
const window = current();
|
|
1308
1502
|
if (!window?.cursor || replicaCursorIsStale(subscription, message.cursor) || message.cursor.revision < window.cursor.revision)
|
|
1309
1503
|
return;
|
|
1310
|
-
const
|
|
1311
|
-
const hashes = await replicaRowsHashes(
|
|
1504
|
+
const hashesWereStored = window.hashes !== undefined;
|
|
1505
|
+
const hashes = window.hashes ?? await replicaRowsHashes(projectReplicaIntegrityRows(this.replica.committedWindowRows(subscription.key), subscription.columns), window.key);
|
|
1312
1506
|
const digest = await replicaHashesDigest(hashes);
|
|
1313
1507
|
if (!message.digest || message.digest !== digest) {
|
|
1314
1508
|
await this.handleReplicaMessage(subscription, { type: "replica.reset", id: subscription.id, path: subscription.path, reason: "integrity-mismatch" }, scope);
|
|
1315
1509
|
return;
|
|
1316
1510
|
}
|
|
1317
|
-
await this.acceptReplicaReady(subscription, message, scope);
|
|
1511
|
+
await this.acceptReplicaReady(subscription, message, hashes, hashesWereStored, scope);
|
|
1318
1512
|
return;
|
|
1319
1513
|
}
|
|
1320
1514
|
if (message.type === "replica.error") {
|
|
@@ -1324,25 +1518,77 @@ export class GonvexClient {
|
|
|
1324
1518
|
}
|
|
1325
1519
|
this.emitReplicaMessage(subscription, message, scope);
|
|
1326
1520
|
}
|
|
1327
|
-
|
|
1521
|
+
enqueueReplicaFrame(operation) {
|
|
1522
|
+
const processing = this.replicaFrames.then(operation);
|
|
1523
|
+
this.replicaFrames = processing.catch(() => {
|
|
1524
|
+
this.replica.setFreshness("verifying");
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
enqueueReplicaTransaction(message) {
|
|
1528
|
+
const scope = this.replicaScope;
|
|
1529
|
+
this.enqueueReplicaFrame(async () => {
|
|
1530
|
+
await this.replica.applyTransaction({
|
|
1531
|
+
cursor: message.cursor,
|
|
1532
|
+
originCommandId: message.originCommandId,
|
|
1533
|
+
provenance: message.provenance,
|
|
1534
|
+
changes: message.changes.map((change) => ({
|
|
1535
|
+
...change,
|
|
1536
|
+
oldValue: asReplicaRow(change.oldValue),
|
|
1537
|
+
newValue: asReplicaRow(change.newValue),
|
|
1538
|
+
})),
|
|
1539
|
+
}, scope);
|
|
1540
|
+
if (message.originCommandId && !this.replica.hasPendingCommand(message.originCommandId)) {
|
|
1541
|
+
await this.ackOptimisticReducer(message.originCommandId);
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
drainPendingReplicaTransactions() {
|
|
1546
|
+
const pending = this.pendingReplicaTransactions.splice(0);
|
|
1547
|
+
for (const message of pending)
|
|
1548
|
+
this.enqueueReplicaTransaction(message);
|
|
1549
|
+
}
|
|
1550
|
+
async acceptReplicaReady(subscription, message, hashes, hashesWereStored, scope = this.replicaScope) {
|
|
1328
1551
|
this.clearReplicaRetry(subscription, true);
|
|
1329
1552
|
subscription.isUpToDate = true;
|
|
1330
1553
|
subscription.opening = false;
|
|
1331
1554
|
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1332
1555
|
const window = this.replica.getWindow(subscription.key);
|
|
1333
1556
|
if (window) {
|
|
1557
|
+
const completeness = message.truncated === true ? "partial" : "complete";
|
|
1558
|
+
const mode = message.mode ?? window.mode;
|
|
1559
|
+
const truncated = message.truncated ?? window.truncated;
|
|
1560
|
+
const readyAlreadyPersisted = hashesWereStored
|
|
1561
|
+
&& window.source === "server"
|
|
1562
|
+
&& window.cursor?.epoch === message.cursor.epoch
|
|
1563
|
+
&& window.cursor.revision === message.cursor.revision
|
|
1564
|
+
&& window.completeness === completeness
|
|
1565
|
+
&& window.mode === mode
|
|
1566
|
+
&& window.truncated === truncated;
|
|
1567
|
+
if (readyAlreadyPersisted) {
|
|
1568
|
+
// Connection-wide freshness remains a separate summary. Collection
|
|
1569
|
+
// state combines it with this subscription's isUpToDate bit, so one
|
|
1570
|
+
// ready window cannot make another hydrated cache authoritative.
|
|
1571
|
+
this.replica.setFreshness("current");
|
|
1572
|
+
this.emitReplicaMessage(subscription, message, scope);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1334
1575
|
await this.replica.replaceWindow({
|
|
1335
|
-
...window, rows: this.replica.
|
|
1336
|
-
completeness
|
|
1337
|
-
mode
|
|
1338
|
-
|
|
1576
|
+
...window, rows: this.replica.committedWindowRows(subscription.key), source: "server", cursor: message.cursor,
|
|
1577
|
+
completeness,
|
|
1578
|
+
mode, truncated,
|
|
1579
|
+
// Persist the exact integrity map verified above. Subsequent auth
|
|
1580
|
+
// rotations can then prove unchanged rows instead of requesting a full
|
|
1581
|
+
// upsert of every entity in every retained collection.
|
|
1582
|
+
hashes,
|
|
1339
1583
|
});
|
|
1340
1584
|
}
|
|
1585
|
+
this.replica.setFreshness("current");
|
|
1341
1586
|
this.emitReplicaMessage(subscription, message, scope);
|
|
1342
1587
|
}
|
|
1343
|
-
handleReplicaWatermark(revision) {
|
|
1588
|
+
async handleReplicaWatermark(revision) {
|
|
1344
1589
|
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
1345
1590
|
return;
|
|
1591
|
+
const eligibleSignatures = [];
|
|
1346
1592
|
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1347
1593
|
const cursor = this.replica.getWindow(subscription.key)?.cursor;
|
|
1348
1594
|
if (!cursor
|
|
@@ -1351,9 +1597,18 @@ export class GonvexClient {
|
|
|
1351
1597
|
|| subscription.opening
|
|
1352
1598
|
|| !this.replica.getWindow(subscription.key)?.hashes)
|
|
1353
1599
|
continue;
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1600
|
+
eligibleSignatures.push(subscription.key);
|
|
1601
|
+
}
|
|
1602
|
+
await this.replica.advanceWatermark(revision, eligibleSignatures, this.replicaScope);
|
|
1603
|
+
this.processedReplicaWatermarkRevision = Math.max(this.processedReplicaWatermarkRevision, revision);
|
|
1604
|
+
for (const pending of this.pendingCalls.values()) {
|
|
1605
|
+
if ((pending.kind !== "reducer" && pending.kind !== "action")
|
|
1606
|
+
|| pending.committedRevision === undefined
|
|
1607
|
+
|| pending.committedRevision > this.processedReplicaWatermarkRevision)
|
|
1608
|
+
continue;
|
|
1609
|
+
const complete = pending.completeAfterReplicaWatermark;
|
|
1610
|
+
pending.completeAfterReplicaWatermark = undefined;
|
|
1611
|
+
complete?.();
|
|
1357
1612
|
}
|
|
1358
1613
|
}
|
|
1359
1614
|
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
@@ -1417,7 +1672,13 @@ export class GonvexClient {
|
|
|
1417
1672
|
this.sendReplicaOpen(subscription);
|
|
1418
1673
|
}
|
|
1419
1674
|
sendReplicaOpen(subscription) {
|
|
1420
|
-
|
|
1675
|
+
// Replica rows are tenant-authorized state. A hook can mount while the
|
|
1676
|
+
// socket is connected but before session.ready/auth.result supplies the
|
|
1677
|
+
// authoritative visibility scope. Opening in that gap produces a correct
|
|
1678
|
+
// server rejection that is nevertheless transient and must not become a
|
|
1679
|
+
// fatal React snapshot error. The directive handlers resume every retained
|
|
1680
|
+
// subscription once the scope is active.
|
|
1681
|
+
if (this.authInFlight || !this.hasAuthoritativeReplicaScope || subscription.listeners.size === 0 || subscription.opening)
|
|
1421
1682
|
return;
|
|
1422
1683
|
const requestScope = this.replicaScope;
|
|
1423
1684
|
subscription.scope = requestScope;
|
|
@@ -1436,16 +1697,19 @@ export class GonvexClient {
|
|
|
1436
1697
|
replicaOpenRequest(subscription) {
|
|
1437
1698
|
const window = this.replica.getWindow(subscription.key);
|
|
1438
1699
|
const cursor = window?.cursor;
|
|
1439
|
-
const
|
|
1440
|
-
const fullIntegrity = cursor !== undefined;
|
|
1441
|
-
|
|
1700
|
+
const hashes = window?.hashes;
|
|
1701
|
+
const fullIntegrity = cursor !== undefined && hashes !== undefined;
|
|
1702
|
+
// Resume protocol state must describe only the committed server window.
|
|
1703
|
+
// windowRows() includes optimistic overlays for rendering and would cause
|
|
1704
|
+
// the server digest to disagree (and advertise uncommitted IDs as deletes).
|
|
1705
|
+
const keys = cursor ? [...(window?.ids ?? [])] : undefined;
|
|
1442
1706
|
return {
|
|
1443
1707
|
id: subscription.id,
|
|
1444
1708
|
path: subscription.path,
|
|
1445
1709
|
args: subscription.args,
|
|
1446
1710
|
cursor,
|
|
1447
1711
|
keys,
|
|
1448
|
-
hashes
|
|
1712
|
+
hashes,
|
|
1449
1713
|
digest: undefined,
|
|
1450
1714
|
fullIntegrity: fullIntegrity || undefined,
|
|
1451
1715
|
};
|
|
@@ -1454,6 +1718,11 @@ export class GonvexClient {
|
|
|
1454
1718
|
this.replicaOpenFlushTimer = undefined;
|
|
1455
1719
|
const subscriptions = Array.from(this.pendingReplicaOpens);
|
|
1456
1720
|
this.pendingReplicaOpens.clear();
|
|
1721
|
+
if (this.authInFlight || !this.hasAuthoritativeReplicaScope) {
|
|
1722
|
+
for (const subscription of subscriptions)
|
|
1723
|
+
subscription.opening = false;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1457
1726
|
const opens = subscriptions
|
|
1458
1727
|
.filter((subscription) => (subscription.opening
|
|
1459
1728
|
&& subscription.listeners.size > 0
|
|
@@ -1516,6 +1785,7 @@ export class GonvexClient {
|
|
|
1516
1785
|
const scope = directive.visibilityScope.trim();
|
|
1517
1786
|
if (this.hasAuthoritativeReplicaScope && this.replicaScope === scope) {
|
|
1518
1787
|
await this.replicaReady;
|
|
1788
|
+
await this.outboxReady;
|
|
1519
1789
|
return;
|
|
1520
1790
|
}
|
|
1521
1791
|
for (const reducerId of this.optimisticReducerIds)
|
|
@@ -1527,8 +1797,13 @@ export class GonvexClient {
|
|
|
1527
1797
|
this.hasAuthoritativeReplicaScope = true;
|
|
1528
1798
|
this.replicaReady = this.replica.activateScope(scope);
|
|
1529
1799
|
this.rotateSubscriptionScopes();
|
|
1530
|
-
await this.replicaReady;
|
|
1531
1800
|
const generation = this.outboxScopeGeneration;
|
|
1801
|
+
// Publish the recovery barrier before yielding to Replica storage. A
|
|
1802
|
+
// reducer may be invoked as soon as the auth result arrives, while the
|
|
1803
|
+
// session.ready scope activation is still hydrating. If outboxReady still
|
|
1804
|
+
// points at the old resolved promise, that reducer can enqueue an inflight
|
|
1805
|
+
// row which the concurrent recovery then mistakes for an abandoned call
|
|
1806
|
+
// and sends a second time with the same command ID.
|
|
1532
1807
|
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
1533
1808
|
await this.outboxReady;
|
|
1534
1809
|
}
|
|
@@ -1606,8 +1881,7 @@ export class GonvexClient {
|
|
|
1606
1881
|
await this.outboxReady;
|
|
1607
1882
|
if (this.drainingOutbox
|
|
1608
1883
|
|| this.manuallyClosed
|
|
1609
|
-
|| !this.
|
|
1610
|
-
|| this.socket.readyState !== WebSocket.OPEN)
|
|
1884
|
+
|| !this.canSendReducerNow())
|
|
1611
1885
|
return;
|
|
1612
1886
|
const drainScope = this.outboxScope;
|
|
1613
1887
|
this.drainingOutbox = true;
|
|
@@ -1619,9 +1893,26 @@ export class GonvexClient {
|
|
|
1619
1893
|
return;
|
|
1620
1894
|
if (scope !== this.outboxScope)
|
|
1621
1895
|
return;
|
|
1896
|
+
if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
|
|
1897
|
+
// Scope recovery may observe an inflight row created by this live
|
|
1898
|
+
// process and reset it to pending under the assumption that a prior
|
|
1899
|
+
// process crashed. The foreground call still owns that command ID.
|
|
1900
|
+
// Restore the durable marker and wait for its real result instead of
|
|
1901
|
+
// registering a second response handler for the same command.
|
|
1902
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
if (!this.canSendReducerNow()) {
|
|
1906
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1622
1909
|
await this.reducerOutbox.markInflight(entry.id);
|
|
1623
1910
|
if (scope !== this.outboxScope)
|
|
1624
1911
|
return;
|
|
1912
|
+
if (!this.canSendReducerNow()) {
|
|
1913
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1625
1916
|
try {
|
|
1626
1917
|
await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
1627
1918
|
await this.reducerOutbox.markCommitted(entry.id);
|
|
@@ -1685,27 +1976,36 @@ export class GonvexClient {
|
|
|
1685
1976
|
if (this.manuallyClosed) {
|
|
1686
1977
|
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
1687
1978
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
path: ref.path,
|
|
1692
|
-
args,
|
|
1693
|
-
idempotencyKey: reducerId,
|
|
1694
|
-
entityKeys: patches.map((patch) => `${patch.entity ?? patch.collection ?? ""}:${patch.rowId}`),
|
|
1695
|
-
patches,
|
|
1696
|
-
state: "inflight",
|
|
1697
|
-
});
|
|
1698
|
-
if (this.manuallyClosed) {
|
|
1699
|
-
await this.reducerOutbox.ack(entry.id);
|
|
1700
|
-
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
1701
|
-
}
|
|
1702
|
-
if (scope !== this.outboxScope) {
|
|
1703
|
-
await this.reducerOutbox.ack(entry.id);
|
|
1704
|
-
throw new GonvexClientError(`Authentication changed before reducer ${ref.path} could be sent.`, { code: "disconnected", path: ref.path, operation: "reducer" });
|
|
1705
|
-
}
|
|
1706
|
-
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
1707
|
-
this.addOptimisticReducer(reducerId, patches);
|
|
1979
|
+
this.directOutboxReducerIds.add(reducerId);
|
|
1980
|
+
let entryId;
|
|
1981
|
+
let entryAttempts = 0;
|
|
1708
1982
|
try {
|
|
1983
|
+
const scope = this.outboxScope;
|
|
1984
|
+
const entry = await this.reducerOutbox.enqueue({
|
|
1985
|
+
scope,
|
|
1986
|
+
path: ref.path,
|
|
1987
|
+
args,
|
|
1988
|
+
idempotencyKey: reducerId,
|
|
1989
|
+
entityKeys: patches.map((patch) => `${patch.entity ?? patch.collection ?? ""}:${patch.rowId}`),
|
|
1990
|
+
patches,
|
|
1991
|
+
state: "inflight",
|
|
1992
|
+
});
|
|
1993
|
+
entryId = entry.id;
|
|
1994
|
+
entryAttempts = entry.attempts;
|
|
1995
|
+
if (this.manuallyClosed) {
|
|
1996
|
+
await this.reducerOutbox.ack(entry.id);
|
|
1997
|
+
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
1998
|
+
}
|
|
1999
|
+
if (scope !== this.outboxScope) {
|
|
2000
|
+
await this.reducerOutbox.ack(entry.id);
|
|
2001
|
+
throw new GonvexClientError(`Authentication changed before reducer ${ref.path} could be sent.`, { code: "disconnected", path: ref.path, operation: "reducer" });
|
|
2002
|
+
}
|
|
2003
|
+
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
2004
|
+
this.addOptimisticReducer(reducerId, patches);
|
|
2005
|
+
if (options.offline === "queue" && !this.canSendReducerNow()) {
|
|
2006
|
+
await this.reducerOutbox.markPending(entry.id);
|
|
2007
|
+
return { status: "queued", reducerId };
|
|
2008
|
+
}
|
|
1709
2009
|
// The direct send is outbox-managed: a crash here replays the entry
|
|
1710
2010
|
// with the same idempotency key, so the server must dedupe it.
|
|
1711
2011
|
const result = await this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs, reducerId, reducerId);
|
|
@@ -1720,12 +2020,32 @@ export class GonvexClient {
|
|
|
1720
2020
|
}
|
|
1721
2021
|
catch (error) {
|
|
1722
2022
|
if (isQueueableReducerError(error) && options.offline === "queue") {
|
|
1723
|
-
|
|
2023
|
+
const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
|
|
2024
|
+
if (queuedEntryId !== undefined) {
|
|
2025
|
+
await this.reducerOutbox.fail(queuedEntryId, reducerErrorMessage(error));
|
|
2026
|
+
// `fail` deliberately records backoff, but it does not own the
|
|
2027
|
+
// client's timer. The foreground queueable path must schedule the
|
|
2028
|
+
// next deterministic drain just like the background drain path.
|
|
2029
|
+
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
|
|
2030
|
+
}
|
|
1724
2031
|
return { status: "queued", reducerId };
|
|
1725
2032
|
}
|
|
1726
|
-
await this.rejectOptimisticReducer(reducerId,
|
|
2033
|
+
await this.rejectOptimisticReducer(reducerId, entryId);
|
|
1727
2034
|
throw error;
|
|
1728
2035
|
}
|
|
2036
|
+
finally {
|
|
2037
|
+
this.directOutboxReducerIds.delete(reducerId);
|
|
2038
|
+
void this.drainOutbox();
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
canSendReducerNow() {
|
|
2042
|
+
if (globalThis.navigator?.onLine === false)
|
|
2043
|
+
return false;
|
|
2044
|
+
const socket = this.socket;
|
|
2045
|
+
if (!socket || socket.readyState !== WebSocket.OPEN || this.authInFlight)
|
|
2046
|
+
return false;
|
|
2047
|
+
const hasConfiguredAuth = Boolean(this.auth.project || this.auth.tenant || this.auth.token || this.auth.fetchToken);
|
|
2048
|
+
return !hasConfiguredAuth || this.authenticatedSocketGeneration === this.socketGeneration;
|
|
1729
2049
|
}
|
|
1730
2050
|
action(ref, args = {}, options = {}) {
|
|
1731
2051
|
return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
|
|
@@ -1777,7 +2097,7 @@ export class GonvexClient {
|
|
|
1777
2097
|
error: message.error,
|
|
1778
2098
|
clientReceivedAtMs: nowMs(),
|
|
1779
2099
|
});
|
|
1780
|
-
reject(new GonvexClientError(message.error
|
|
2100
|
+
reject(new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, { code: "server", path: ref.path, operation: "query" }));
|
|
1781
2101
|
}
|
|
1782
2102
|
});
|
|
1783
2103
|
this.sendOneShotQuery(query);
|
|
@@ -1906,11 +2226,34 @@ export class GonvexClient {
|
|
|
1906
2226
|
}
|
|
1907
2227
|
this.pendingCalls.set(id, pending);
|
|
1908
2228
|
this.handlers.set(id, (message) => {
|
|
2229
|
+
if (message.type === "control.watermark") {
|
|
2230
|
+
const complete = pending.completeAfterControlWatermark;
|
|
2231
|
+
pending.completeAfterControlWatermark = undefined;
|
|
2232
|
+
complete?.();
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
1909
2235
|
if (kind === "reducer" && message.type === "reducer.result") {
|
|
1910
|
-
settle();
|
|
1911
2236
|
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
1912
|
-
|
|
1913
|
-
|
|
2237
|
+
const complete = () => {
|
|
2238
|
+
settle();
|
|
2239
|
+
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2240
|
+
resolve(message.result);
|
|
2241
|
+
};
|
|
2242
|
+
const committedRevision = message.committedRevision;
|
|
2243
|
+
if (pending.scope === "control" && this.serverCapabilities.controlWatermark === 1) {
|
|
2244
|
+
pending.completeAfterControlWatermark = complete;
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (pending.scope === "tenant"
|
|
2248
|
+
&& this.serverCapabilities.replicaWatermark === 1
|
|
2249
|
+
&& typeof committedRevision === "number"
|
|
2250
|
+
&& Number.isSafeInteger(committedRevision)
|
|
2251
|
+
&& committedRevision > this.processedReplicaWatermarkRevision) {
|
|
2252
|
+
pending.committedRevision = committedRevision;
|
|
2253
|
+
pending.completeAfterReplicaWatermark = complete;
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
complete();
|
|
1914
2257
|
}
|
|
1915
2258
|
if (kind === "reducer" && message.type === "reducer.error") {
|
|
1916
2259
|
settle();
|
|
@@ -1918,9 +2261,26 @@ export class GonvexClient {
|
|
|
1918
2261
|
reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
|
|
1919
2262
|
}
|
|
1920
2263
|
if (kind === "action" && message.type === "action.result") {
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2264
|
+
const complete = () => {
|
|
2265
|
+
settle();
|
|
2266
|
+
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
2267
|
+
resolve(message.result);
|
|
2268
|
+
};
|
|
2269
|
+
const committedRevision = message.committedRevision;
|
|
2270
|
+
if (pending.scope === "control" && this.serverCapabilities.controlWatermark === 1) {
|
|
2271
|
+
pending.completeAfterControlWatermark = complete;
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (pending.scope === "tenant"
|
|
2275
|
+
&& this.serverCapabilities.replicaWatermark === 1
|
|
2276
|
+
&& typeof committedRevision === "number"
|
|
2277
|
+
&& Number.isSafeInteger(committedRevision)
|
|
2278
|
+
&& committedRevision > this.processedReplicaWatermarkRevision) {
|
|
2279
|
+
pending.committedRevision = committedRevision;
|
|
2280
|
+
pending.completeAfterReplicaWatermark = complete;
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
complete();
|
|
1924
2284
|
}
|
|
1925
2285
|
if (kind === "action" && message.type === "action.error") {
|
|
1926
2286
|
settle();
|
|
@@ -1954,6 +2314,9 @@ export class GonvexClient {
|
|
|
1954
2314
|
sendSubscription(subscription) {
|
|
1955
2315
|
if (subscription.listeners.size === 0)
|
|
1956
2316
|
return;
|
|
2317
|
+
if (subscription.executionScope !== "control"
|
|
2318
|
+
&& (this.authInFlight || (!!this.auth.tenant && !this.hasAuthoritativeReplicaScope)))
|
|
2319
|
+
return;
|
|
1957
2320
|
if (subscription.socketGeneration === this.socketGeneration)
|
|
1958
2321
|
return;
|
|
1959
2322
|
subscription.scope = this.replicaScope;
|
|
@@ -1983,7 +2346,9 @@ export class GonvexClient {
|
|
|
1983
2346
|
const subscribes = subscriptions
|
|
1984
2347
|
.filter((subscription) => (subscription.listeners.size > 0
|
|
1985
2348
|
&& subscription.socketGeneration === this.socketGeneration
|
|
1986
|
-
&& this.querySubscriptions.get(subscription.key) === subscription
|
|
2349
|
+
&& this.querySubscriptions.get(subscription.key) === subscription
|
|
2350
|
+
&& !(subscription.executionScope !== "control"
|
|
2351
|
+
&& (this.authInFlight || (!!this.auth.tenant && !this.hasAuthoritativeReplicaScope)))))
|
|
1987
2352
|
.map((subscription) => ({
|
|
1988
2353
|
id: subscription.id,
|
|
1989
2354
|
path: subscription.path,
|
|
@@ -1995,10 +2360,12 @@ export class GonvexClient {
|
|
|
1995
2360
|
this.send({ type: "query.subscribeMany", subscribes: subscribes.slice(offset, offset + maxReplicaBatchOpens) });
|
|
1996
2361
|
}
|
|
1997
2362
|
}
|
|
1998
|
-
resumeQuerySubscriptions() {
|
|
2363
|
+
resumeQuerySubscriptions(force = false) {
|
|
1999
2364
|
for (const subscription of this.querySubscriptions.values()) {
|
|
2000
2365
|
if (subscription.listeners.size === 0)
|
|
2001
2366
|
continue;
|
|
2367
|
+
if (force)
|
|
2368
|
+
subscription.socketGeneration = undefined;
|
|
2002
2369
|
this.sendSubscription(subscription);
|
|
2003
2370
|
}
|
|
2004
2371
|
}
|
|
@@ -2140,6 +2507,7 @@ export class GonvexClient {
|
|
|
2140
2507
|
}, delay);
|
|
2141
2508
|
}
|
|
2142
2509
|
resetReplicaScopeState() {
|
|
2510
|
+
this.processedReplicaWatermarkRevision = 0;
|
|
2143
2511
|
for (const subscription of this.querySubscriptions.values()) {
|
|
2144
2512
|
subscription.lastMessage = undefined;
|
|
2145
2513
|
subscription.serverSettled = false;
|
|
@@ -2182,8 +2550,13 @@ export class GonvexClient {
|
|
|
2182
2550
|
subscription.scope = this.replicaScope;
|
|
2183
2551
|
this.handlers.set(subscription.id, (message) => {
|
|
2184
2552
|
const scope = subscription.scope ?? this.replicaScope;
|
|
2185
|
-
|
|
2186
|
-
|
|
2553
|
+
// Snapshot/delta/ready frames for every Replica Collection must be
|
|
2554
|
+
// handled in wire order. In particular, `ready` verifies the entities
|
|
2555
|
+
// written by the preceding snapshot or delta. Running these handlers
|
|
2556
|
+
// concurrently after an auth-scope rotation lets `ready` inspect the
|
|
2557
|
+
// old window, falsely reset it for an integrity mismatch, and leave a
|
|
2558
|
+
// retained entity stale until another server change happens.
|
|
2559
|
+
this.enqueueReplicaFrame(() => this.handleReplicaMessage(subscription, message, scope));
|
|
2187
2560
|
});
|
|
2188
2561
|
}
|
|
2189
2562
|
}
|
|
@@ -2286,6 +2659,7 @@ export class GonvexClient {
|
|
|
2286
2659
|
}
|
|
2287
2660
|
sendAuthFrame() {
|
|
2288
2661
|
const id = randomID();
|
|
2662
|
+
this.latestAuthFrameId = id;
|
|
2289
2663
|
this.managedAuthAttempt?.ids.add(id);
|
|
2290
2664
|
this.sendNow({
|
|
2291
2665
|
type: "auth",
|