@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { replicaHashesDigest, replicaRowsHashes, replicaRowKey } from "./replica-integrity.js";
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.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
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.rejectPendingCalls((call) => new GonvexClientError(`Authentication scope changed while waiting for ${call.kind} ${call.path}`, { code: "auth", path: call.path, operation: call.kind }));
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: "auth", path: query.path, operation: "query" }));
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
- this.resetReplicaScopeState();
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
- this.authInFlight = false;
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
- this.resumeQuerySubscriptions();
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
- const scope = this.replicaScope;
512
- void this.replica.applyTransaction({
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
- this.handleReplicaWatermark(message.revision);
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, { code: "server", path: ref.path, operation: "query" });
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
- void this.handleReplicaMessage(subscription, message, scope)
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.snapshot" || message.type === "replica.ready") {
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.version();
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.version();
1056
- if (stateVersion === version)
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
- snapshotState = this.replica.collectionState(key);
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
- // A snapshot is still verifying until replica.ready supplies the
1228
- // authoritative budget/truncation result.
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
- hashes: message.hashes,
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
- hashes: message.hashes ?? prior?.hashes,
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 rows = this.replica.windowRows(subscription.key);
1311
- const hashes = await replicaRowsHashes(rows, window.key);
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
- async acceptReplicaReady(subscription, message, scope = this.replicaScope) {
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.windowRows(subscription.key), source: "server", cursor: message.cursor,
1336
- completeness: message.truncated === true ? "partial" : "complete",
1337
- mode: message.mode ?? window.mode, truncated: message.truncated ?? window.truncated,
1338
- hashes: window.hashes,
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
- const window = this.replica.getWindow(subscription.key);
1355
- if (window)
1356
- void this.replica.replaceWindow({ ...window, rows: this.replica.windowRows(subscription.key), cursor: { ...cursor, revision } });
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
- if (subscription.listeners.size === 0 || subscription.opening)
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 rows = this.replica.windowRows(subscription.key);
1440
- const fullIntegrity = cursor !== undefined;
1441
- const keys = fullIntegrity ? rows.map((row) => replicaRowKey(row, window?.key ?? "id")).filter(Boolean) : undefined;
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: undefined,
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.socket
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
- const scope = this.outboxScope;
1689
- const entry = await this.reducerOutbox.enqueue({
1690
- scope,
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
- await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
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, entry.id);
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, { code: "server", path: ref.path, operation: "query" }));
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
- this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
1913
- resolve(message.result);
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
- settle();
1922
- this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
1923
- resolve(message.result);
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
- void this.handleReplicaMessage(subscription, message, scope)
2186
- .catch(() => this.replica.setFreshness("verifying"));
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",