@gonvex/client 0.1.30 → 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/README.md +62 -7
- package/dist/index.d.ts +20 -5
- package/dist/index.js +289 -46
- package/dist/index.js.map +1 -1
- 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 +18 -8
- package/dist/outbox.js +110 -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";
|
|
@@ -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((
|
|
140
|
-
this.
|
|
159
|
+
this.unsubscribeOverlay = this.overlay.subscribe((entity) => {
|
|
160
|
+
this.emitOptimisticEntity(entity);
|
|
141
161
|
});
|
|
142
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
1152
|
+
emitOptimisticEntity(entity) {
|
|
1082
1153
|
for (const subscription of this.syncSubscriptions.values()) {
|
|
1083
|
-
if (subscription.
|
|
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
|
-
|
|
1347
|
-
const
|
|
1348
|
-
if (this.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
1391
|
-
|
|
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.
|
|
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
|
-
|
|
1424
|
-
this.
|
|
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
|
-
}
|
|
1623
|
+
}
|
|
1624
|
+
catch (error) {
|
|
1427
1625
|
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
|
-
});
|
|
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
|
|
1517
|
-
*
|
|
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
|
-
|
|
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(
|
|
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;
|