@gonvex/client 0.1.29 → 0.1.31

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,7 +1,7 @@
1
1
  import { createQueryCacheStore, defaultQueryCacheReadTimeoutMs, } from "./query-cache.js";
2
2
  import { createSyncStore, syncHashesDigest, syncRowsHashes, } from "./sync-store.js";
3
3
  import { GonvexErrorReporter } from "./error-reporter.js";
4
- import { OptimisticOverlay } from "./optimistic.js";
4
+ import { OptimisticOverlay, optimisticPatchesFromReference, } from "./optimistic.js";
5
5
  import { createMutationOutbox, } from "./outbox.js";
6
6
  export * from "./cache.js";
7
7
  export * from "./cache-coordinator.js";
@@ -16,6 +16,22 @@ export * from "./error-reporter.js";
16
16
  export * from "./optimistic.js";
17
17
  export * from "./outbox.js";
18
18
  export * from "./signals.js";
19
+ function syncCursorIsStale(subscription, cursor) {
20
+ if (subscription.retiredEpochs.has(cursor.epoch))
21
+ return true;
22
+ const floor = subscription.cursorFloor;
23
+ return floor?.epoch === cursor.epoch && cursor.revision < floor.revision;
24
+ }
25
+ function raiseSyncCursorFloor(subscription, cursor) {
26
+ if (subscription.cursorFloor && subscription.cursorFloor.epoch !== cursor.epoch) {
27
+ subscription.retiredEpochs.add(subscription.cursorFloor.epoch);
28
+ }
29
+ if (!subscription.cursorFloor
30
+ || subscription.cursorFloor.epoch !== cursor.epoch
31
+ || cursor.revision > subscription.cursorFloor.revision) {
32
+ subscription.cursorFloor = cursor;
33
+ }
34
+ }
19
35
  /**
20
36
  * Typed error for every rejected Gonvex operation. `code` distinguishes
21
37
  * server-side failures from transport-level ones so apps can decide whether
@@ -98,7 +114,11 @@ export class GonvexClient {
98
114
  mutationOutbox;
99
115
  overlay = new OptimisticOverlay();
100
116
  optimisticMutationIds = new Set();
117
+ optimisticOutboxEntryIds = new Map();
101
118
  outboxReady;
119
+ outboxScope = "";
120
+ outboxScopeGeneration = 0;
121
+ outboxEphemeralScope = randomID();
102
122
  unsubscribeOutbox;
103
123
  unsubscribeOverlay;
104
124
  drainingOutbox = false;
@@ -136,10 +156,14 @@ export class GonvexClient {
136
156
  this.unsubscribeOutbox = this.mutationOutbox.subscribe(() => {
137
157
  void this.drainOutbox();
138
158
  });
139
- this.unsubscribeOverlay = this.overlay.subscribe((collection) => {
140
- this.emitOptimisticCollection(collection);
159
+ this.unsubscribeOverlay = this.overlay.subscribe((entity) => {
160
+ this.emitOptimisticEntity(entity);
141
161
  });
142
- this.outboxReady = this.restoreOutbox();
162
+ // Defer the first restore by one microtask. Apps commonly construct the
163
+ // client and immediately install a cached token/identity; waiting lets the
164
+ // durable queue select that authenticated scope instead of briefly
165
+ // restoring an anonymous user's entries.
166
+ this.outboxReady = Promise.resolve().then(() => this.activateOutboxScope());
143
167
  this.timeouts = {
144
168
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
145
169
  mutationTimeoutMs: options.timeouts?.mutationTimeoutMs ?? DEFAULT_MUTATION_TIMEOUT_MS,
@@ -157,7 +181,7 @@ export class GonvexClient {
157
181
  /** Number of mutations waiting for a definitive server result. */
158
182
  async outboxCount() {
159
183
  await this.outboxReady;
160
- return this.mutationOutbox.count();
184
+ return this.mutationOutbox.count(this.outboxScope);
161
185
  }
162
186
  connectionState() {
163
187
  const inflightMutations = countPendingCalls(this.pendingCalls, "mutation");
@@ -231,8 +255,10 @@ export class GonvexClient {
231
255
  this.resetQueryCacheScope();
232
256
  }
233
257
  this.auth = nextAuth;
234
- if (scopeMayChange)
258
+ if (scopeMayChange) {
259
+ void this.activateOutboxScope();
235
260
  this.recoverWarmSyncDirective();
261
+ }
236
262
  if (auth.tenant !== undefined)
237
263
  this.errorReporter?.setTenant(auth.tenant);
238
264
  if (auth.project !== undefined)
@@ -385,6 +411,9 @@ export class GonvexClient {
385
411
  }
386
412
  close() {
387
413
  this.manuallyClosed = true;
414
+ if (isEphemeralOutboxScope(this.outboxScope)) {
415
+ void this.mutationOutbox.clear(this.outboxScope);
416
+ }
388
417
  if (this.reconnectTimer) {
389
418
  clearTimeout(this.reconnectTimer);
390
419
  this.reconnectTimer = undefined;
@@ -517,7 +546,7 @@ export class GonvexClient {
517
546
  else if (cached) {
518
547
  queueMicrotask(() => {
519
548
  if (existing.listeners.has(onMessage))
520
- onMessage(cached);
549
+ onMessage(this.materializeQueryMessage(existing, cached));
521
550
  });
522
551
  }
523
552
  return () => this.unsubscribeQueryListener(key, onMessage);
@@ -526,10 +555,14 @@ export class GonvexClient {
526
555
  id: randomID(),
527
556
  key,
528
557
  path: ref.path,
558
+ projection: ref.optimistic?.projection,
529
559
  args,
530
560
  listeners: new Set([onMessage]),
531
561
  serverSettled: false,
532
562
  };
563
+ if (subscription.projection) {
564
+ this.overlay.expectSource(subscription.key, subscription.projection.entity);
565
+ }
533
566
  this.querySubscriptions.set(key, subscription);
534
567
  this.handlers.set(subscription.id, (message) => {
535
568
  const normalized = this.normalizeSubscriptionMessage(subscription, message);
@@ -564,8 +597,13 @@ export class GonvexClient {
564
597
  clientReceivedAtMs: nowMs(),
565
598
  });
566
599
  }
600
+ const outgoing = this.materializeQueryMessage(subscription, message);
567
601
  for (const listener of Array.from(subscription.listeners)) {
568
- listener(message);
602
+ listener(outgoing);
603
+ }
604
+ if (message.type === "query.result") {
605
+ this.acknowledgeOptimisticSource(subscription.key, message.mutationIds);
606
+ this.acknowledgeOptimisticQuerySnapshot(subscription, message.result);
569
607
  }
570
608
  if (message.type === "query.result") {
571
609
  this.persistQueryResult(subscription, message);
@@ -592,6 +630,7 @@ export class GonvexClient {
592
630
  subscription.lastRevision = message.throughRevision;
593
631
  subscription.revisionSocketGeneration = this.socketGeneration;
594
632
  subscription.serverSettled = true;
633
+ this.acknowledgeOptimisticSource(subscription.key, message.mutationIds);
595
634
  // Progress advances freshness without waking React/query listeners.
596
635
  return undefined;
597
636
  }
@@ -626,6 +665,7 @@ export class GonvexClient {
626
665
  cacheScope: message.cacheScope,
627
666
  cacheRevision: message.cacheRevision,
628
667
  subscriptionRevision: message.subscriptionRevision,
668
+ mutationIds: message.mutationIds,
629
669
  };
630
670
  }
631
671
  if (message.type === "query.pagePatch") {
@@ -648,7 +688,7 @@ export class GonvexClient {
648
688
  const metadata = isJsonRecord(message.result) ? message.result : {};
649
689
  subscription.lastRevision = message.subscriptionRevision;
650
690
  subscription.revisionSocketGeneration = this.socketGeneration;
651
- return { ...message, type: "query.result", result: { ...previous.result, ...metadata, page } };
691
+ return { ...message, type: "query.result", result: { ...previous.result, ...metadata, page }, mutationIds: message.mutationIds };
652
692
  }
653
693
  if (message.type === "query.objectPatch") {
654
694
  if (!sameRevision(message.baseRevision, subscription.lastRevision)) {
@@ -678,7 +718,7 @@ export class GonvexClient {
678
718
  }
679
719
  subscription.lastRevision = message.subscriptionRevision;
680
720
  subscription.revisionSocketGeneration = this.socketGeneration;
681
- return { ...message, type: "query.result", result };
721
+ return { ...message, type: "query.result", result, mutationIds: message.mutationIds };
682
722
  }
683
723
  if (message.type === "query.result" && message.subscriptionRevision) {
684
724
  if (!this.acceptRevision(subscription, message.subscriptionRevision))
@@ -763,6 +803,7 @@ export class GonvexClient {
763
803
  id: randomID(),
764
804
  key,
765
805
  path: ref.path,
806
+ entity: ref.optimistic?.projection?.entity ?? ref.path,
766
807
  args,
767
808
  listeners: new Set([onMessage]),
768
809
  rows: [],
@@ -774,7 +815,9 @@ export class GonvexClient {
774
815
  hashes: {},
775
816
  forceFullIntegrity: false,
776
817
  verificationGeneration: 0,
818
+ retiredEpochs: new Set(),
777
819
  };
820
+ this.overlay.expectSource(subscription.key, subscription.entity);
778
821
  this.syncSubscriptions.set(key, subscription);
779
822
  this.handlers.set(subscription.id, (message) => this.handleSyncMessage(subscription, message));
780
823
  this.startSync(subscription);
@@ -844,6 +887,8 @@ export class GonvexClient {
844
887
  // delayed snapshot could roll a verified collection back to old rows.
845
888
  if (!subscription.opening)
846
889
  return;
890
+ if (syncCursorIsStale(subscription, message.cursor))
891
+ return;
847
892
  if (subscription.cursor
848
893
  && message.cursor.epoch === subscription.cursor.epoch
849
894
  && message.cursor.revision < subscription.cursor.revision)
@@ -853,6 +898,7 @@ export class GonvexClient {
853
898
  subscription.isUpToDate = false;
854
899
  subscription.opening = false;
855
900
  subscription.cursor = message.cursor;
901
+ raiseSyncCursorFloor(subscription, message.cursor);
856
902
  subscription.keyField = message.key;
857
903
  subscription.mode = message.mode;
858
904
  subscription.truncated = undefined;
@@ -872,6 +918,8 @@ export class GonvexClient {
872
918
  return;
873
919
  }
874
920
  if (message.type === "sync.delta") {
921
+ if (syncCursorIsStale(subscription, message.cursor))
922
+ return;
875
923
  if (subscription.cursor && (message.cursor.epoch !== subscription.cursor.epoch
876
924
  || message.cursor.revision < subscription.cursor.revision
877
925
  || (message.cursor.revision === subscription.cursor.revision
@@ -881,6 +929,7 @@ export class GonvexClient {
881
929
  subscription.verificationGeneration += 1;
882
930
  subscription.isUpToDate = false;
883
931
  subscription.cursor = message.cursor;
932
+ raiseSyncCursorFloor(subscription, message.cursor);
884
933
  subscription.rows = applySyncDelta(subscription.rows, subscription.keyField, message.upserts ?? [], message.deleted ?? [], subscription.maxRows, subscription.maxBytes, subscription.orderBy, subscription.orderDirection);
885
934
  for (const key of message.deleted ?? [])
886
935
  delete subscription.hashes[key];
@@ -903,6 +952,7 @@ export class GonvexClient {
903
952
  };
904
953
  subscription.lastMessage = snapshot;
905
954
  this.emitSyncMessage(subscription, snapshot);
955
+ this.acknowledgeOptimisticSource(subscription.key, message.mutationIds);
906
956
  this.persistSyncDelta(subscription, message.upserts ?? [], message.deleted ?? []);
907
957
  return;
908
958
  }
@@ -957,7 +1007,8 @@ export class GonvexClient {
957
1007
  }
958
1008
  if (message.type === "sync.ready") {
959
1009
  if (!subscription.cursor || (message.cursor.epoch !== subscription.cursor.epoch
960
- || message.cursor.revision < subscription.cursor.revision))
1010
+ || message.cursor.revision < subscription.cursor.revision
1011
+ || syncCursorIsStale(subscription, message.cursor)))
961
1012
  return;
962
1013
  const generation = ++subscription.verificationGeneration;
963
1014
  if (!message.digest && this.serverCapabilities.syncIntegrity === 1) {
@@ -1025,6 +1076,7 @@ export class GonvexClient {
1025
1076
  subscription.isUpToDate = true;
1026
1077
  subscription.opening = false;
1027
1078
  subscription.cursor = message.cursor;
1079
+ raiseSyncCursorFloor(subscription, message.cursor);
1028
1080
  subscription.mode = message.mode ?? subscription.mode;
1029
1081
  subscription.truncated = message.truncated;
1030
1082
  subscription.integrityDigest = verifiedDigest;
@@ -1052,6 +1104,7 @@ export class GonvexClient {
1052
1104
  || subscription.integrityEpoch !== cursor.epoch)
1053
1105
  continue;
1054
1106
  subscription.cursor = { ...cursor, revision };
1107
+ raiseSyncCursorFloor(subscription, subscription.cursor);
1055
1108
  this.scheduleSyncWatermarkPersistence(subscription);
1056
1109
  }
1057
1110
  }
@@ -1069,21 +1122,69 @@ export class GonvexClient {
1069
1122
  const outgoing = this.materializeSyncMessage(subscription, message);
1070
1123
  for (const listener of Array.from(subscription.listeners))
1071
1124
  listener(outgoing);
1125
+ if (message.type === "sync.snapshot") {
1126
+ const settled = this.overlay.acknowledgeMatching(subscription.key, subscription.entity, message.result, message.key);
1127
+ for (const mutationId of settled)
1128
+ void this.ackOptimisticMutation(mutationId);
1129
+ }
1072
1130
  }
1073
1131
  materializeSyncMessage(subscription, message) {
1074
1132
  if (message.type !== "sync.snapshot")
1075
1133
  return message;
1076
1134
  return {
1077
1135
  ...message,
1078
- result: this.overlay.apply(subscription.path, message.result, message.key),
1136
+ result: this.overlay.apply(subscription.key, subscription.entity, message.result, message.key),
1137
+ };
1138
+ }
1139
+ materializeQueryMessage(subscription, message) {
1140
+ const projection = subscription.projection;
1141
+ if (!projection || message.type !== "query.result")
1142
+ return message;
1143
+ const projected = rowsAtPath(message.result, projection.resultPath);
1144
+ if (!projected)
1145
+ return message;
1146
+ const materialized = this.overlay.apply(subscription.key, projection.entity, projected.rows, projection.key);
1147
+ return {
1148
+ ...message,
1149
+ result: replaceRowsAtPath(message.result, projection.resultPath, materialized, projected.scalar),
1079
1150
  };
1080
1151
  }
1081
- emitOptimisticCollection(collection) {
1152
+ emitOptimisticEntity(entity) {
1082
1153
  for (const subscription of this.syncSubscriptions.values()) {
1083
- if (subscription.path !== collection || subscription.lastMessage?.type !== "sync.snapshot")
1154
+ if (subscription.entity !== entity)
1155
+ continue;
1156
+ this.overlay.expectSource(subscription.key, entity);
1157
+ if (subscription.lastMessage?.type !== "sync.snapshot")
1084
1158
  continue;
1085
1159
  this.emitSyncMessage(subscription, subscription.lastMessage);
1086
1160
  }
1161
+ for (const subscription of this.querySubscriptions.values()) {
1162
+ if (subscription.projection?.entity !== entity)
1163
+ continue;
1164
+ this.overlay.expectSource(subscription.key, entity);
1165
+ if (subscription.lastMessage?.type !== "query.result")
1166
+ continue;
1167
+ const outgoing = this.materializeQueryMessage(subscription, subscription.lastMessage);
1168
+ for (const listener of Array.from(subscription.listeners))
1169
+ listener(outgoing);
1170
+ this.acknowledgeOptimisticQuerySnapshot(subscription, subscription.lastMessage.result);
1171
+ }
1172
+ }
1173
+ acknowledgeOptimisticSource(source, mutationIds) {
1174
+ const settled = this.overlay.acknowledge(source, mutationIds);
1175
+ for (const mutationId of settled)
1176
+ void this.ackOptimisticMutation(mutationId);
1177
+ }
1178
+ acknowledgeOptimisticQuerySnapshot(subscription, result) {
1179
+ const projection = subscription.projection;
1180
+ if (!projection)
1181
+ return;
1182
+ const projected = rowsAtPath(result, projection.resultPath);
1183
+ if (!projected)
1184
+ return;
1185
+ const settled = this.overlay.acknowledgeMatching(subscription.key, projection.entity, projected.rows, projection.key);
1186
+ for (const mutationId of settled)
1187
+ void this.ackOptimisticMutation(mutationId);
1087
1188
  }
1088
1189
  markSyncSubscriptionsOutOfDate() {
1089
1190
  for (const subscription of this.syncSubscriptions.values()) {
@@ -1146,6 +1247,7 @@ export class GonvexClient {
1146
1247
  // the ready that follows this resume must not rewrite them.
1147
1248
  subscription.persistedRows = cached.rows;
1148
1249
  subscription.cursor = cached.cursor;
1250
+ raiseSyncCursorFloor(subscription, cached.cursor);
1149
1251
  subscription.keyField = cached.keyField;
1150
1252
  subscription.mode = cached.mode;
1151
1253
  subscription.truncated = cached.truncated;
@@ -1281,6 +1383,8 @@ export class GonvexClient {
1281
1383
  this.clearSyncRetry(latest);
1282
1384
  this.pendingSyncOpens.delete(latest);
1283
1385
  this.syncSubscriptions.delete(key);
1386
+ for (const mutationId of this.overlay.removeSource(key))
1387
+ void this.ackOptimisticMutation(mutationId);
1284
1388
  this.handlers.delete(latest.id);
1285
1389
  this.send({ type: "sync.close", id: latest.id });
1286
1390
  }, this.syncSubscriptionRetentionMs);
@@ -1343,12 +1447,40 @@ export class GonvexClient {
1343
1447
  subscription.persistedRows = subscription.rows;
1344
1448
  this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
1345
1449
  }
1346
- async restoreOutbox() {
1347
- const entries = await this.mutationOutbox.loadAll();
1348
- if (this.manuallyClosed)
1450
+ activateOutboxScope() {
1451
+ const scope = mutationOutboxScope(this.url, this.auth, this.outboxEphemeralScope);
1452
+ if (scope === this.outboxScope)
1453
+ return this.outboxReady ?? Promise.resolve();
1454
+ const previousScope = this.outboxScope;
1455
+ const generation = ++this.outboxScopeGeneration;
1456
+ // Pending state from the previous authenticated identity must disappear
1457
+ // from every live projection immediately. Its durable rows remain scoped
1458
+ // in IndexedDB and can be resumed only if that identity returns.
1459
+ for (const mutationId of this.optimisticMutationIds)
1460
+ this.overlay.reject(mutationId);
1461
+ this.optimisticMutationIds.clear();
1462
+ this.optimisticOutboxEntryIds.clear();
1463
+ if (isEphemeralOutboxScope(previousScope)) {
1464
+ void this.mutationOutbox.clear(previousScope);
1465
+ }
1466
+ this.outboxScope = scope;
1467
+ const ready = this.restoreOutbox(scope, generation);
1468
+ this.outboxReady = ready;
1469
+ return ready;
1470
+ }
1471
+ async restoreOutbox(scope, generation) {
1472
+ const entries = await this.mutationOutbox.loadAll(scope);
1473
+ if (this.manuallyClosed
1474
+ || generation !== this.outboxScopeGeneration
1475
+ || scope !== this.outboxScope)
1349
1476
  return;
1350
1477
  for (const entry of entries) {
1351
- this.addOptimisticMutation(entry.idempotencyKey, entry.patches ?? []);
1478
+ if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
1479
+ await this.mutationOutbox.ack(entry.id);
1480
+ continue;
1481
+ }
1482
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
1483
+ this.addOptimisticMutation(entry.idempotencyKey, entry.patches ?? [], entry.state === "committed");
1352
1484
  }
1353
1485
  const nextAttemptAt = Math.min(...entries
1354
1486
  .filter((entry) => entry.state === "pending")
@@ -1356,20 +1488,31 @@ export class GonvexClient {
1356
1488
  if (Number.isFinite(nextAttemptAt) && nextAttemptAt > Date.now()) {
1357
1489
  this.scheduleOutboxDrain(nextAttemptAt - Date.now());
1358
1490
  }
1491
+ // If this scope was installed after the socket authenticated, no reconnect
1492
+ // or new enqueue may occur to wake the queue. The await inside drainOutbox
1493
+ // yields until this restore promise resolves, then safely resumes it.
1494
+ void this.drainOutbox();
1359
1495
  }
1360
- addOptimisticMutation(mutationId, patches) {
1496
+ addOptimisticMutation(mutationId, patches, accepted = false) {
1361
1497
  if (patches.length === 0 || this.optimisticMutationIds.has(mutationId))
1362
1498
  return;
1363
1499
  this.optimisticMutationIds.add(mutationId);
1364
- this.overlay.add(mutationId, patches);
1500
+ this.overlay.add(mutationId, patches, { accepted });
1365
1501
  }
1366
- settleOptimisticMutation(mutationId) {
1367
- this.optimisticMutationIds.delete(mutationId);
1368
- this.overlay.settle(mutationId);
1502
+ async settleOptimisticMutation(mutationId) {
1503
+ await Promise.all(this.overlay.accept(mutationId).map((settledId) => this.ackOptimisticMutation(settledId)));
1369
1504
  }
1370
- rejectOptimisticMutation(mutationId) {
1505
+ async rejectOptimisticMutation(mutationId, knownEntryId) {
1371
1506
  this.optimisticMutationIds.delete(mutationId);
1372
1507
  this.overlay.reject(mutationId);
1508
+ await this.ackOptimisticMutation(mutationId, knownEntryId);
1509
+ }
1510
+ async ackOptimisticMutation(mutationId, knownEntryId) {
1511
+ const entryId = knownEntryId ?? this.optimisticOutboxEntryIds.get(mutationId);
1512
+ this.optimisticOutboxEntryIds.delete(mutationId);
1513
+ this.optimisticMutationIds.delete(mutationId);
1514
+ if (entryId !== undefined)
1515
+ await this.mutationOutbox.ack(entryId);
1373
1516
  }
1374
1517
  async drainOutbox() {
1375
1518
  await this.outboxReady;
@@ -1378,22 +1521,32 @@ export class GonvexClient {
1378
1521
  || !this.socket
1379
1522
  || this.socket.readyState !== WebSocket.OPEN)
1380
1523
  return;
1524
+ const drainScope = this.outboxScope;
1381
1525
  this.drainingOutbox = true;
1382
1526
  try {
1383
1527
  while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
1384
- const entry = await this.mutationOutbox.nextReady(Date.now());
1528
+ const scope = this.outboxScope;
1529
+ const entry = await this.mutationOutbox.nextReady(scope, Date.now());
1385
1530
  if (!entry)
1386
1531
  return;
1532
+ if (scope !== this.outboxScope)
1533
+ return;
1387
1534
  await this.mutationOutbox.markInflight(entry.id);
1535
+ if (scope !== this.outboxScope)
1536
+ return;
1388
1537
  try {
1389
1538
  await this.call("mutation", { kind: "mutation", path: entry.path }, entry.args, this.timeouts.mutationTimeoutMs, entry.idempotencyKey);
1390
- await this.mutationOutbox.ack(entry.id);
1391
- this.settleOptimisticMutation(entry.idempotencyKey);
1539
+ await this.mutationOutbox.markCommitted(entry.id);
1540
+ if ((entry.patches?.length ?? 0) > 0) {
1541
+ await this.settleOptimisticMutation(entry.idempotencyKey);
1542
+ }
1543
+ else {
1544
+ await this.ackOptimisticMutation(entry.idempotencyKey, entry.id);
1545
+ }
1392
1546
  }
1393
1547
  catch (error) {
1394
1548
  if (error instanceof GonvexClientError && error.code === "server") {
1395
- await this.mutationOutbox.ack(entry.id);
1396
- this.rejectOptimisticMutation(entry.idempotencyKey);
1549
+ await this.rejectOptimisticMutation(entry.idempotencyKey, entry.id);
1397
1550
  continue;
1398
1551
  }
1399
1552
  await this.mutationOutbox.fail(entry.id, mutationErrorMessage(error));
@@ -1404,6 +1557,9 @@ export class GonvexClient {
1404
1557
  }
1405
1558
  finally {
1406
1559
  this.drainingOutbox = false;
1560
+ if (!this.manuallyClosed && drainScope !== this.outboxScope) {
1561
+ void this.drainOutbox();
1562
+ }
1407
1563
  }
1408
1564
  }
1409
1565
  scheduleOutboxDrain(delay) {
@@ -1418,25 +1574,61 @@ export class GonvexClient {
1418
1574
  }
1419
1575
  mutation(ref, args = {}, options = {}) {
1420
1576
  const mutationId = randomID();
1421
- const patches = options.optimistic ?? [];
1577
+ const patches = options.optimistic
1578
+ ?? optimisticPatchesFromReference(ref.optimistic?.mutation, args);
1579
+ if (patches.length === 0 && options.offline !== "queue") {
1580
+ return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId);
1581
+ }
1582
+ return this.runOptimisticMutation(ref, args, options, mutationId, patches);
1583
+ }
1584
+ async runOptimisticMutation(ref, args, options, mutationId, patches) {
1585
+ // The startup recovery transaction converts abandoned inflight entries to
1586
+ // pending. Finish it before inserting a brand-new direct send, otherwise
1587
+ // recovery can mistake that live entry for a crashed mutation and race the
1588
+ // direct call through the background drain.
1589
+ await this.outboxReady;
1590
+ if (this.manuallyClosed) {
1591
+ throw new GonvexClientError(`Gonvex client was closed before mutation ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "mutation" });
1592
+ }
1593
+ const scope = this.outboxScope;
1594
+ const entry = await this.mutationOutbox.enqueue({
1595
+ scope,
1596
+ path: ref.path,
1597
+ args,
1598
+ idempotencyKey: mutationId,
1599
+ entityKeys: patches.map((patch) => `${patch.entity ?? patch.collection ?? ""}:${patch.rowId}`),
1600
+ patches,
1601
+ state: "inflight",
1602
+ });
1603
+ if (this.manuallyClosed) {
1604
+ await this.mutationOutbox.ack(entry.id);
1605
+ throw new GonvexClientError(`Gonvex client was closed before mutation ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "mutation" });
1606
+ }
1607
+ if (scope !== this.outboxScope) {
1608
+ await this.mutationOutbox.ack(entry.id);
1609
+ throw new GonvexClientError(`Authentication changed before mutation ${ref.path} could be sent.`, { code: "disconnected", path: ref.path, operation: "mutation" });
1610
+ }
1611
+ this.optimisticOutboxEntryIds.set(mutationId, entry.id);
1422
1612
  this.addOptimisticMutation(mutationId, patches);
1423
- return this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId).then((result) => {
1424
- this.settleOptimisticMutation(mutationId);
1613
+ try {
1614
+ const result = await this.call("mutation", ref, args, options.timeoutMs ?? this.timeouts.mutationTimeoutMs, mutationId);
1615
+ await this.mutationOutbox.markCommitted(entry.id);
1616
+ if (patches.length > 0) {
1617
+ await this.settleOptimisticMutation(mutationId);
1618
+ }
1619
+ else {
1620
+ await this.ackOptimisticMutation(mutationId, entry.id);
1621
+ }
1425
1622
  return result;
1426
- }).catch(async (error) => {
1623
+ }
1624
+ catch (error) {
1427
1625
  if (isQueueableMutationError(error) && options.offline === "queue") {
1428
- await this.mutationOutbox.enqueue({
1429
- path: ref.path,
1430
- args,
1431
- idempotencyKey: mutationId,
1432
- entityKeys: patches.map((patch) => patch.rowId),
1433
- patches,
1434
- });
1626
+ await this.mutationOutbox.fail(entry.id, mutationErrorMessage(error));
1435
1627
  return { status: "queued", mutationId };
1436
1628
  }
1437
- this.rejectOptimisticMutation(mutationId);
1629
+ await this.rejectOptimisticMutation(mutationId, entry.id);
1438
1630
  throw error;
1439
- });
1631
+ }
1440
1632
  }
1441
1633
  action(ref, args = {}, options = {}) {
1442
1634
  return this.call("action", ref, args, options.timeoutMs ?? this.timeouts.actionTimeoutMs);
@@ -1513,8 +1705,9 @@ export class GonvexClient {
1513
1705
  * Flush a queue of mutations in one `mutation.callMany` frame (queue order,
1514
1706
  * one websocket round trip). Each entry settles independently — a failed
1515
1707
  * call does not reject the batch — so offline queues can apply per-row
1516
- * outcomes. Falls back to sequential `mutation` calls on runtimes that do
1517
- * not advertise the `mutationBatch` capability.
1708
+ * outcomes. Falls back to the standard per-mutation path when the runtime
1709
+ * lacks batching or when a call needs generated/explicit optimism or durable
1710
+ * offline queuing, so there is never a second mutation-state implementation.
1518
1711
  */
1519
1712
  async mutationMany(calls, options = {}) {
1520
1713
  if (calls.length === 0)
@@ -1529,7 +1722,10 @@ export class GonvexClient {
1529
1722
  ? error
1530
1723
  : new GonvexClientError(String(error), { code: "server", path, operation: "mutation" }),
1531
1724
  }));
1532
- if (this.serverCapabilities.mutationBatch !== 1) {
1725
+ const requiresStandardMutationPath = options.offline === "queue"
1726
+ || options.optimistic !== undefined
1727
+ || calls.some((call) => call.ref.optimistic?.mutation !== undefined);
1728
+ if (this.serverCapabilities.mutationBatch !== 1 || requiresStandardMutationPath) {
1533
1729
  const outcomes = [];
1534
1730
  for (const call of calls) {
1535
1731
  outcomes.push(await settle(this.mutation(call.ref, call.args ?? {}, options), call.ref.path));
@@ -1632,6 +1828,8 @@ export class GonvexClient {
1632
1828
  if (!latest || latest.listeners.size > 0)
1633
1829
  return;
1634
1830
  this.querySubscriptions.delete(key);
1831
+ for (const mutationId of this.overlay.removeSource(key))
1832
+ void this.ackOptimisticMutation(mutationId);
1635
1833
  this.send({ type: "query.unsubscribe", id: latest.id });
1636
1834
  setTimeout(() => this.handlers.delete(latest.id), 500);
1637
1835
  }, this.querySubscriptionRetentionMs);
@@ -1886,6 +2084,8 @@ export class GonvexClient {
1886
2084
  subscription.integrityEpoch = undefined;
1887
2085
  subscription.forceFullIntegrity = false;
1888
2086
  subscription.cursor = undefined;
2087
+ subscription.cursorFloor = undefined;
2088
+ subscription.retiredEpochs.clear();
1889
2089
  subscription.lastMessage = undefined;
1890
2090
  subscription.cacheReadGeneration = undefined;
1891
2091
  subscription.opening = false;
@@ -1923,9 +2123,11 @@ export class GonvexClient {
1923
2123
  cacheRevision: cached.revision,
1924
2124
  };
1925
2125
  subscription.lastMessage = message;
2126
+ const outgoing = this.materializeQueryMessage(subscription, message);
1926
2127
  for (const listener of Array.from(subscription.listeners)) {
1927
- listener(message);
2128
+ listener(outgoing);
1928
2129
  }
2130
+ this.acknowledgeOptimisticQuerySnapshot(subscription, message.result);
1929
2131
  }).catch(() => {
1930
2132
  // Persistent cache failures never affect the server query path.
1931
2133
  }).finally(() => {
@@ -2181,6 +2383,28 @@ export class GonvexClient {
2181
2383
  }
2182
2384
  }
2183
2385
  }
2386
+ function rowsAtPath(result, path) {
2387
+ let current = result;
2388
+ for (const segment of path) {
2389
+ if (!isJsonRecord(current))
2390
+ return undefined;
2391
+ current = current[segment];
2392
+ }
2393
+ if (Array.isArray(current))
2394
+ return { rows: current, scalar: false };
2395
+ if (isJsonRecord(current))
2396
+ return { rows: [current], scalar: true };
2397
+ return undefined;
2398
+ }
2399
+ function replaceRowsAtPath(result, path, rows, scalar) {
2400
+ if (path.length === 0)
2401
+ return scalar ? (rows[0] ?? null) : rows;
2402
+ if (!isJsonRecord(result))
2403
+ return result;
2404
+ const [head, ...tail] = path;
2405
+ const current = result[head];
2406
+ return { ...result, [head]: replaceRowsAtPath(current ?? null, tail, rows, scalar) };
2407
+ }
2184
2408
  function querySubscriptionKey(ref, args) {
2185
2409
  return `${ref.path}\u0000${stableStringify(args)}`;
2186
2410
  }
@@ -2415,6 +2639,25 @@ function sameAuthTokenIdentity(left, right) {
2415
2639
  const rightIdentity = authIdentityKey(right);
2416
2640
  return leftIdentity !== "" && leftIdentity === rightIdentity;
2417
2641
  }
2642
+ function mutationOutboxScope(url, auth, ephemeralScope) {
2643
+ const identity = authIdentityKey(auth);
2644
+ if (identity)
2645
+ return ["identity", url, identity].join("\u0000");
2646
+ if (auth.token || auth.identity || auth.fetchToken) {
2647
+ // Opaque tokens (or credentials installed before tenant selection) do not
2648
+ // expose a stable user key. A per-client scope preserves current-session
2649
+ // queue semantics without ever restoring those rows under another user.
2650
+ return ["ephemeral-auth", url, ephemeralScope].join("\u0000");
2651
+ }
2652
+ // Anonymous/dev-auth clients still need a stable namespace, but it must be
2653
+ // isolated by deployment and tenant. Once an authenticated identity is
2654
+ // installed, applyAuth switches away from this scope before restoring or
2655
+ // sending its durable mutations.
2656
+ return ["anonymous", url, auth.project ?? "", auth.tenant ?? ""].join("\u0000");
2657
+ }
2658
+ function isEphemeralOutboxScope(scope) {
2659
+ return scope.startsWith("ephemeral-auth\u0000");
2660
+ }
2418
2661
  function queryCacheDirectiveFromAuthResult(result) {
2419
2662
  if (!isJsonRecord(result))
2420
2663
  return undefined;