@gonvex/client 0.1.30 → 0.1.32

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