@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/README.md +62 -7
- package/dist/index.d.ts +25 -5
- package/dist/index.js +302 -49
- package/dist/index.js.map +1 -1
- package/dist/kv-stores.d.ts +61 -0
- package/dist/kv-stores.js +447 -0
- package/dist/kv-stores.js.map +1 -0
- package/dist/optimistic.d.ts +46 -14
- package/dist/optimistic.js +216 -27
- package/dist/optimistic.js.map +1 -1
- package/dist/outbox.d.ts +76 -8
- package/dist/outbox.js +290 -29
- package/dist/outbox.js.map +1 -1
- package/package.json +2 -2
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((
|
|
140
|
-
this.
|
|
160
|
+
this.unsubscribeOverlay = this.overlay.subscribe((entity) => {
|
|
161
|
+
this.emitOptimisticEntity(entity);
|
|
141
162
|
});
|
|
142
|
-
|
|
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(
|
|
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.
|
|
1137
|
+
result: this.overlay.apply(subscription.key, subscription.entity, message.result, message.key),
|
|
1079
1138
|
};
|
|
1080
1139
|
}
|
|
1081
|
-
|
|
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.
|
|
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
|
-
|
|
1347
|
-
const
|
|
1348
|
-
if (this.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
1391
|
-
|
|
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.
|
|
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
|
-
|
|
1424
|
-
|
|
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
|
-
}
|
|
1626
|
+
}
|
|
1627
|
+
catch (error) {
|
|
1427
1628
|
if (isQueueableMutationError(error) && options.offline === "queue") {
|
|
1428
|
-
await this.mutationOutbox.
|
|
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
|
|
1517
|
-
*
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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;
|