@gonvex/client 0.1.32 → 0.3.1
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 +125 -95
- package/dist/control.d.ts +416 -0
- package/dist/control.js +210 -0
- package/dist/control.js.map +1 -0
- package/dist/error-reporter.d.ts +20 -6
- package/dist/error-reporter.js +55 -27
- package/dist/error-reporter.js.map +1 -1
- package/dist/index.d.ts +176 -133
- package/dist/index.js +1177 -1140
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-replica.d.ts +20 -0
- package/dist/indexeddb-replica.js +193 -0
- package/dist/indexeddb-replica.js.map +1 -0
- package/dist/kv-stores.d.ts +2 -48
- package/dist/kv-stores.js +24 -411
- package/dist/kv-stores.js.map +1 -1
- package/dist/local-replica.d.ts +240 -0
- package/dist/local-replica.js +590 -0
- package/dist/local-replica.js.map +1 -0
- package/dist/optimistic.d.ts +34 -55
- package/dist/optimistic.js +70 -269
- package/dist/optimistic.js.map +1 -1
- package/dist/outbox.d.ts +25 -25
- package/dist/outbox.js +27 -27
- package/dist/outbox.js.map +1 -1
- package/dist/query-expression.d.ts +58 -0
- package/dist/query-expression.js +164 -0
- package/dist/query-expression.js.map +1 -0
- package/dist/replica-integrity.d.ts +5 -0
- package/dist/replica-integrity.js +58 -0
- package/dist/replica-integrity.js.map +1 -0
- package/package.json +4 -4
- package/dist/browser-cache-client.d.ts +0 -77
- package/dist/browser-cache-client.js +0 -156
- package/dist/browser-cache-client.js.map +0 -1
- package/dist/browser-cache-shared-worker.d.ts +0 -35
- package/dist/browser-cache-shared-worker.js +0 -118
- package/dist/browser-cache-shared-worker.js.map +0 -1
- package/dist/browser-cache.d.ts +0 -43
- package/dist/browser-cache.js +0 -67
- package/dist/browser-cache.js.map +0 -1
- package/dist/browser-capabilities.d.ts +0 -21
- package/dist/browser-capabilities.js +0 -31
- package/dist/browser-capabilities.js.map +0 -1
- package/dist/cache-coordinator.d.ts +0 -37
- package/dist/cache-coordinator.js +0 -109
- package/dist/cache-coordinator.js.map +0 -1
- package/dist/cache.d.ts +0 -74
- package/dist/cache.js +0 -120
- package/dist/cache.js.map +0 -1
- package/dist/persistent-cache.d.ts +0 -41
- package/dist/persistent-cache.js +0 -103
- package/dist/persistent-cache.js.map +0 -1
- package/dist/query-cache.d.ts +0 -88
- package/dist/query-cache.js +0 -346
- package/dist/query-cache.js.map +0 -1
- package/dist/sync-store.d.ts +0 -96
- package/dist/sync-store.js +0 -500
- package/dist/sync-store.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,29 +1,53 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createSyncStore, syncHashesDigest, syncRowsHashes, } from "./sync-store.js";
|
|
1
|
+
import { replicaHashesDigest, replicaRowsHashes, replicaRowKey } from "./replica-integrity.js";
|
|
3
2
|
import { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export * from "./browser-cache-client.js";
|
|
10
|
-
export * from "./browser-cache-shared-worker.js";
|
|
11
|
-
export * from "./browser-capabilities.js";
|
|
12
|
-
export * from "./persistent-cache.js";
|
|
13
|
-
export * from "./query-cache.js";
|
|
14
|
-
export * from "./sync-store.js";
|
|
3
|
+
export { GonvexErrorReporter } from "./error-reporter.js";
|
|
4
|
+
import { optimisticPatchesFromReference, } from "./optimistic.js";
|
|
5
|
+
import { createReducerOutbox, } from "./outbox.js";
|
|
6
|
+
import { LocalReplica, } from "./local-replica.js";
|
|
7
|
+
import { runOfflineLiveQuery } from "./query-expression.js";
|
|
15
8
|
export * from "./error-reporter.js";
|
|
16
9
|
export * from "./optimistic.js";
|
|
17
10
|
export * from "./outbox.js";
|
|
18
11
|
export * from "./kv-stores.js";
|
|
19
12
|
export * from "./signals.js";
|
|
20
|
-
|
|
13
|
+
// Keep the mutable LocalReplica implementation private to GonvexClient. The
|
|
14
|
+
// public package exposes only the read-only view plus storage/value types.
|
|
15
|
+
export { MemoryLocalReplicaStorage, } from "./local-replica.js";
|
|
16
|
+
export * from "./query-expression.js";
|
|
17
|
+
export * from "./indexeddb-replica.js";
|
|
18
|
+
export * from "./control.js";
|
|
19
|
+
function asReplicaRow(value) {
|
|
20
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
21
|
+
? value
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
function createLocalReplicaView(replica) {
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
cursor: () => replica.cursor(),
|
|
27
|
+
freshness: () => replica.freshness(),
|
|
28
|
+
version: () => replica.version(),
|
|
29
|
+
subscribe: (listener) => replica.subscribe(listener),
|
|
30
|
+
hasPendingCommand: (commandId) => replica.hasPendingCommand(commandId),
|
|
31
|
+
getWindow: (signature) => replica.getWindow(signature),
|
|
32
|
+
listWindows: () => replica.listWindows(),
|
|
33
|
+
windowRows: (signature) => replica.windowRows(signature),
|
|
34
|
+
entity: (entity, id) => replica.entity(entity, id),
|
|
35
|
+
entityBatch: (entity, ids) => replica.entityBatch(entity, ids),
|
|
36
|
+
entityRows: (entity) => replica.entityRows(entity),
|
|
37
|
+
entityCompleteness: (entity) => replica.entityCompleteness(entity),
|
|
38
|
+
liveQuery: (signature) => replica.liveQuery(signature),
|
|
39
|
+
collectionState: (signature) => replica.collectionState(signature),
|
|
40
|
+
hasLiveQuery: (signature) => replica.hasLiveQuery(signature),
|
|
41
|
+
snapshot: () => replica.snapshot(),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function replicaCursorIsStale(subscription, cursor) {
|
|
21
45
|
if (subscription.retiredEpochs.has(cursor.epoch))
|
|
22
46
|
return true;
|
|
23
47
|
const floor = subscription.cursorFloor;
|
|
24
48
|
return floor?.epoch === cursor.epoch && cursor.revision < floor.revision;
|
|
25
49
|
}
|
|
26
|
-
function
|
|
50
|
+
function raiseReplicaCursorFloor(subscription, cursor) {
|
|
27
51
|
if (subscription.cursorFloor && subscription.cursorFloor.epoch !== cursor.epoch) {
|
|
28
52
|
subscription.retiredEpochs.add(subscription.cursorFloor.epoch);
|
|
29
53
|
}
|
|
@@ -40,9 +64,9 @@ function raiseSyncCursorFloor(subscription, cursor) {
|
|
|
40
64
|
*
|
|
41
65
|
* - `server`: the runtime executed the function and returned an error.
|
|
42
66
|
* - `timeout`: no response arrived within the operation timeout. For
|
|
43
|
-
*
|
|
67
|
+
* reducers/actions the write may or may not have been applied.
|
|
44
68
|
* - `disconnected`: the socket dropped while the operation was pending.
|
|
45
|
-
*
|
|
69
|
+
* Reducers/actions fail closed unless a reducer opted into the outbox.
|
|
46
70
|
* - `closed`: the client was explicitly closed.
|
|
47
71
|
* - `auth`: authentication was rejected.
|
|
48
72
|
*/
|
|
@@ -59,39 +83,33 @@ export class GonvexClientError extends Error {
|
|
|
59
83
|
}
|
|
60
84
|
}
|
|
61
85
|
export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
|
|
62
|
-
export const
|
|
86
|
+
export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
|
|
63
87
|
export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
|
|
64
88
|
// Small collections can send their row hashes immediately and repair in one
|
|
65
89
|
// round trip. Everything else resumes with one 64-byte digest and only sends
|
|
66
90
|
// the hash map when the server proves that something actually differs
|
|
67
|
-
// (
|
|
91
|
+
// (replica.needHashes) — the server verifies digest-only resumes with zero row
|
|
68
92
|
// data on the unchanged path, so a reload uploads bytes, not hash maps.
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
// this client-side prevents one oversized page from stranding every sync in a
|
|
93
|
+
// Must match the runtime's per-frame replica.openMany admission limit. Keeping
|
|
94
|
+
// this client-side prevents one oversized page from stranding every replica in a
|
|
72
95
|
// batch behind a frame-level rejection.
|
|
73
|
-
const
|
|
96
|
+
const maxReplicaBatchOpens = 256;
|
|
74
97
|
// A wedged IndexedDB (observed in Chrome: open() never fires any event, so no
|
|
75
98
|
// rejection ever reaches the store's error handling) must degrade the warm
|
|
76
99
|
// start into a cold open — never into a permanently empty screen. Reads
|
|
77
100
|
// normally settle in a few milliseconds.
|
|
78
|
-
const syncStoreReadTimeoutMs = 1_000;
|
|
79
|
-
// Watermarks can arrive for every tenant revision. Bound cursor-only IndexedDB
|
|
80
|
-
// writes per collection while keeping the in-memory resume cursor immediate.
|
|
81
|
-
const syncWatermarkPersistDelayMs = 1_000;
|
|
82
101
|
export class GonvexClient {
|
|
83
102
|
url;
|
|
84
103
|
socket;
|
|
85
104
|
handlers = new Map();
|
|
86
105
|
querySubscriptions = new Map();
|
|
87
|
-
|
|
106
|
+
replicaSubscriptions = new Map();
|
|
88
107
|
oneShotQueries = new Map();
|
|
89
108
|
telemetryHandlers = new Set();
|
|
90
109
|
pendingMessages = [];
|
|
91
|
-
|
|
110
|
+
pendingReplicaOpens = new Set();
|
|
92
111
|
pendingQuerySubscribes = new Set();
|
|
93
|
-
|
|
94
|
-
syncOpenFlushTimer;
|
|
112
|
+
replicaOpenFlushTimer;
|
|
95
113
|
querySubscribeFlushTimer;
|
|
96
114
|
serverCapabilities = {};
|
|
97
115
|
auth = {};
|
|
@@ -105,32 +123,25 @@ export class GonvexClient {
|
|
|
105
123
|
// or a fresh send cycle starts, so a bad token can't refresh-loop forever.
|
|
106
124
|
authRetriedAfterError = false;
|
|
107
125
|
authErrorHandlers = new Set();
|
|
126
|
+
managedAuthAttempt;
|
|
108
127
|
telemetryEnabled = false;
|
|
109
|
-
queryCache;
|
|
110
|
-
queryCacheWaitForScope;
|
|
111
|
-
queryCacheReadTimeoutMs;
|
|
112
128
|
querySubscriptionRetentionMs;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
replicaSubscriptionRetentionMs;
|
|
130
|
+
reducerOutbox;
|
|
131
|
+
replica;
|
|
132
|
+
replicaView;
|
|
133
|
+
optimisticReducerIds = new Set();
|
|
118
134
|
optimisticOutboxEntryIds = new Map();
|
|
119
135
|
outboxReady;
|
|
120
136
|
outboxScope = "";
|
|
121
137
|
outboxScopeGeneration = 0;
|
|
122
138
|
outboxEphemeralScope = randomID();
|
|
139
|
+
replicaScope = "";
|
|
140
|
+
hasAuthoritativeReplicaScope = false;
|
|
141
|
+
replicaReady = Promise.resolve();
|
|
123
142
|
unsubscribeOutbox;
|
|
124
|
-
unsubscribeOverlay;
|
|
125
143
|
drainingOutbox = false;
|
|
126
144
|
outboxDrainTimer;
|
|
127
|
-
queryCacheDirective;
|
|
128
|
-
queryCacheGeneration = 0;
|
|
129
|
-
// Sync collections live under a visibility-only scope that survives query
|
|
130
|
-
// cache rotations (deploys); their warm reads are guarded separately.
|
|
131
|
-
syncScopeGeneration = 0;
|
|
132
|
-
queryCacheNegotiatedSocketGeneration;
|
|
133
|
-
syncIdentityGeneration = 0;
|
|
134
145
|
sessionScopeHandlers = new Set();
|
|
135
146
|
errorReporter;
|
|
136
147
|
reconnectTimer;
|
|
@@ -139,6 +150,7 @@ export class GonvexClient {
|
|
|
139
150
|
manuallyClosed = false;
|
|
140
151
|
pendingCalls = new Map();
|
|
141
152
|
connectionStateHandlers = new Set();
|
|
153
|
+
supportCommandHandlers = new Set();
|
|
142
154
|
isWebSocketConnected = false;
|
|
143
155
|
hasEverConnected = false;
|
|
144
156
|
connectionCount = 0;
|
|
@@ -147,45 +159,72 @@ export class GonvexClient {
|
|
|
147
159
|
this.url = url;
|
|
148
160
|
this.auth = authFromOptions(options);
|
|
149
161
|
this.telemetryEnabled = options.telemetry === true;
|
|
150
|
-
this.queryCache = createQueryCacheStore(options.queryCache);
|
|
151
|
-
this.queryCacheWaitForScope = options.queryCache !== undefined && options.queryCache !== false;
|
|
152
|
-
this.queryCacheReadTimeoutMs = queryCacheReadTimeout(options.queryCache === false ? undefined : options.queryCache?.readTimeoutMs);
|
|
153
162
|
this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
163
|
+
this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
|
|
164
|
+
this.reducerOutbox = createReducerOutbox(options.outbox);
|
|
165
|
+
this.replica = new LocalReplica(options.localReplica?.storage);
|
|
166
|
+
this.replicaView = createLocalReplicaView(this.replica);
|
|
167
|
+
this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
|
|
158
168
|
void this.drainOutbox();
|
|
159
169
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.
|
|
170
|
+
// Select the initial identity scope synchronously so subscriptions created
|
|
171
|
+
// immediately after the client cannot capture an empty placeholder scope.
|
|
172
|
+
// A same-tick setAuth supersedes this activation by generation before its
|
|
173
|
+
// snapshot can publish, so anonymous rows still cannot flash on screen.
|
|
174
|
+
const initialScope = reducerOutboxScope(this.url, this.auth, this.outboxEphemeralScope);
|
|
175
|
+
this.outboxScope = initialScope;
|
|
176
|
+
this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
|
|
177
|
+
this.outboxScopeGeneration += 1;
|
|
178
|
+
this.replicaReady = this.replica.activateScope(this.replicaScope, true);
|
|
179
|
+
this.outboxReady = Promise.resolve();
|
|
168
180
|
this.timeouts = {
|
|
169
181
|
queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
|
|
170
|
-
|
|
182
|
+
reducerTimeoutMs: options.timeouts?.reducerTimeoutMs ?? DEFAULT_REDUCER_TIMEOUT_MS,
|
|
171
183
|
actionTimeoutMs: options.timeouts?.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
|
|
172
184
|
};
|
|
173
185
|
if (options.errorReporting && options.project) {
|
|
174
|
-
this.errorReporter = new GonvexErrorReporter({
|
|
186
|
+
this.errorReporter = new GonvexErrorReporter({
|
|
187
|
+
project: options.project,
|
|
188
|
+
tenant: options.tenant,
|
|
189
|
+
...options.errorReporting,
|
|
190
|
+
transport: (type, payload) => this.sendNativeError(type, payload),
|
|
191
|
+
});
|
|
175
192
|
}
|
|
176
|
-
this.recoverWarmSyncDirective();
|
|
177
193
|
}
|
|
178
|
-
/** The
|
|
179
|
-
get
|
|
180
|
-
return this.
|
|
194
|
+
/** The single normalized authoritative + optimistic application data store. */
|
|
195
|
+
get localReplica() {
|
|
196
|
+
return this.replicaView;
|
|
197
|
+
}
|
|
198
|
+
replicaSignature(ref, args = {}) {
|
|
199
|
+
return querySubscriptionKey(ref, args);
|
|
200
|
+
}
|
|
201
|
+
/** Read one persisted Live Query membership without starting another transport subscription. */
|
|
202
|
+
retainedLiveQuery(signature) {
|
|
203
|
+
return this.replica.liveQuery(signature);
|
|
204
|
+
}
|
|
205
|
+
/** Resolve an ordered ID batch from one normalized entity store. */
|
|
206
|
+
replicaEntities(entity, ids) {
|
|
207
|
+
return this.replica.entityBatch(entity, ids);
|
|
181
208
|
}
|
|
182
|
-
/**
|
|
209
|
+
/** Read rows and server-owned completeness for a persisted Replica Collection. */
|
|
210
|
+
replicaCollectionState(ref, args = {}) {
|
|
211
|
+
return this.replica.collectionState(this.replicaSignature(ref, args));
|
|
212
|
+
}
|
|
213
|
+
/** Run the generated Live Query plan over the bounded normalized cache. */
|
|
214
|
+
offlineLiveQuery(ref, args = {}) {
|
|
215
|
+
if (!ref.live?.plan) {
|
|
216
|
+
return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
|
|
217
|
+
}
|
|
218
|
+
const queryArgs = isJsonRecord(args) ? args : {};
|
|
219
|
+
return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity));
|
|
220
|
+
}
|
|
221
|
+
/** Number of reducers waiting for a definitive server result. */
|
|
183
222
|
async outboxCount() {
|
|
184
223
|
await this.outboxReady;
|
|
185
|
-
return this.
|
|
224
|
+
return this.reducerOutbox.count(this.outboxScope);
|
|
186
225
|
}
|
|
187
226
|
connectionState() {
|
|
188
|
-
const
|
|
227
|
+
const inflightReducers = countPendingCalls(this.pendingCalls, "reducer");
|
|
189
228
|
const inflightActions = countPendingCalls(this.pendingCalls, "action");
|
|
190
229
|
const inflightOneShotQueries = this.oneShotQueries.size;
|
|
191
230
|
return {
|
|
@@ -193,8 +232,8 @@ export class GonvexClient {
|
|
|
193
232
|
hasEverConnected: this.hasEverConnected,
|
|
194
233
|
connectionCount: this.connectionCount,
|
|
195
234
|
connectionRetries: this.reconnectAttempt,
|
|
196
|
-
hasInflightRequests:
|
|
197
|
-
|
|
235
|
+
hasInflightRequests: inflightReducers + inflightActions + inflightOneShotQueries > 0,
|
|
236
|
+
inflightReducers,
|
|
198
237
|
inflightActions,
|
|
199
238
|
inflightOneShotQueries,
|
|
200
239
|
};
|
|
@@ -218,6 +257,7 @@ export class GonvexClient {
|
|
|
218
257
|
}
|
|
219
258
|
}
|
|
220
259
|
setAuth(auth) {
|
|
260
|
+
this.cancelManagedAuthAttempt("Authentication was replaced by a newer session.");
|
|
221
261
|
this.applyAuth(auth);
|
|
222
262
|
// The caller owns auth now: a token fetch still in flight from the
|
|
223
263
|
// previous installation must not clobber this one when it resolves.
|
|
@@ -228,6 +268,27 @@ export class GonvexClient {
|
|
|
228
268
|
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
229
269
|
}
|
|
230
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Atomically install credentials and wait for the runtime to accept them.
|
|
273
|
+
* This is used by provider-owned, memory-only authentication transitions
|
|
274
|
+
* such as developer mode. Applications should normally use their auth
|
|
275
|
+
* provider rather than calling this method directly.
|
|
276
|
+
*/
|
|
277
|
+
authenticate(auth) {
|
|
278
|
+
this.cancelManagedAuthAttempt("Authentication was replaced by a newer session.");
|
|
279
|
+
this.applyAuth(auth);
|
|
280
|
+
this.authFetchGeneration += 1;
|
|
281
|
+
const promise = new Promise((resolve, reject) => {
|
|
282
|
+
this.managedAuthAttempt = { ids: new Set(), resolve, reject };
|
|
283
|
+
});
|
|
284
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
285
|
+
this.sendAuth(true, { useFetcher: !hasOwn(auth, "token") });
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
this.connect();
|
|
289
|
+
}
|
|
290
|
+
return promise;
|
|
291
|
+
}
|
|
231
292
|
/**
|
|
232
293
|
* Subscribe to unrecoverable auth rejections: the server refused the
|
|
233
294
|
* credentials and, when a token fetcher is installed, a force-refreshed
|
|
@@ -253,12 +314,21 @@ export class GonvexClient {
|
|
|
253
314
|
// e.g. installing the hint after its token is already live — are inert.
|
|
254
315
|
|| (hasOwn(auth, "identity") && !sameAuthTokenIdentity(this.auth, nextAuth));
|
|
255
316
|
if (scopeMayChange) {
|
|
256
|
-
this.
|
|
317
|
+
this.pendingMessages.length = 0;
|
|
318
|
+
this.rejectPendingCalls((call) => new GonvexClientError(`Authentication scope changed while waiting for ${call.kind} ${call.path}`, { code: "auth", path: call.path, operation: call.kind }));
|
|
319
|
+
for (const query of this.oneShotQueries.values()) {
|
|
320
|
+
if (query.timeoutTimer)
|
|
321
|
+
clearTimeout(query.timeoutTimer);
|
|
322
|
+
this.handlers.delete(query.id);
|
|
323
|
+
query.reject(new GonvexClientError(`Authentication scope changed while waiting for Query ${query.path}`, { code: "auth", path: query.path, operation: "query" }));
|
|
324
|
+
}
|
|
325
|
+
this.oneShotQueries.clear();
|
|
326
|
+
this.resetReplicaScopeState();
|
|
257
327
|
}
|
|
258
328
|
this.auth = nextAuth;
|
|
259
329
|
if (scopeMayChange) {
|
|
260
330
|
void this.activateOutboxScope();
|
|
261
|
-
this.
|
|
331
|
+
this.rotateSubscriptionScopes();
|
|
262
332
|
}
|
|
263
333
|
if (auth.tenant !== undefined)
|
|
264
334
|
this.errorReporter?.setTenant(auth.tenant);
|
|
@@ -285,8 +355,10 @@ export class GonvexClient {
|
|
|
285
355
|
}
|
|
286
356
|
this.reconnectAttempt = 0;
|
|
287
357
|
this.isWebSocketConnected = true;
|
|
358
|
+
this.replica.setFreshness("verifying");
|
|
288
359
|
this.hasEverConnected = true;
|
|
289
360
|
this.connectionCount += 1;
|
|
361
|
+
this.errorReporter?.connectionRestored?.();
|
|
290
362
|
this.sendAuth(false);
|
|
291
363
|
if (isReconnect)
|
|
292
364
|
this.resubscribeQueries(generation);
|
|
@@ -297,21 +369,22 @@ export class GonvexClient {
|
|
|
297
369
|
if (this.socket !== socket || this.manuallyClosed)
|
|
298
370
|
return;
|
|
299
371
|
this.isWebSocketConnected = false;
|
|
300
|
-
this.
|
|
372
|
+
this.replica.setFreshness("offline");
|
|
373
|
+
this.markReplicaSubscriptionsOutOfDate();
|
|
301
374
|
this.authInFlight = false;
|
|
302
375
|
if (this.authWatchdogTimer) {
|
|
303
376
|
clearTimeout(this.authWatchdogTimer);
|
|
304
377
|
this.authWatchdogTimer = undefined;
|
|
305
378
|
}
|
|
306
379
|
// A subscription queued for the old socket is superseded by the complete
|
|
307
|
-
// resubscribe below. Queued
|
|
380
|
+
// resubscribe below. Queued reducers/actions are rejected below, so
|
|
308
381
|
// drop them too — flushing them after reconnect would fire writes whose
|
|
309
382
|
// callers already saw a rejection.
|
|
310
383
|
this.pendingMessages.length = 0;
|
|
311
|
-
//
|
|
384
|
+
// Reducers/actions must fail closed on transport loss: silently
|
|
312
385
|
// replaying a non-idempotent write after reconnect is unsafe, and
|
|
313
386
|
// leaving the promise pending hangs the caller forever.
|
|
314
|
-
this.rejectPendingCalls((call) => new GonvexClientError(`Connection lost while waiting for ${call.kind} ${call.path}. The operation may or may not have been applied.`, { code: "disconnected", path: call.path, operation: call.kind }));
|
|
387
|
+
this.rejectPendingCalls((call) => new GonvexClientError(`Connection lost while waiting for ${call.kind} ${call.path}. The operation may or may not have been applied.`, { code: "disconnected", path: call.path, operation: call.kind }), (call) => call.scope !== "control");
|
|
315
388
|
this.scheduleReconnect();
|
|
316
389
|
this.notifyConnectionState();
|
|
317
390
|
});
|
|
@@ -327,17 +400,27 @@ export class GonvexClient {
|
|
|
327
400
|
}
|
|
328
401
|
if (message.type === "session.ready") {
|
|
329
402
|
this.serverCapabilities = message.capabilities ?? {};
|
|
403
|
+
if (!message.replica) {
|
|
404
|
+
if (this.hasControlPlaneWork() || (!!this.auth.token && !this.auth.tenant)) {
|
|
405
|
+
this.flushPendingMessages();
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
this.rejectMissingReplicaDirective();
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const ready = this.activateReplicaDirective(message.replica);
|
|
330
413
|
if (!this.auth.token && !this.auth.tenant) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
414
|
+
void ready
|
|
415
|
+
.then(() => {
|
|
416
|
+
this.resumeQuerySubscriptions();
|
|
417
|
+
this.resumeReplicaSubscriptions();
|
|
418
|
+
})
|
|
419
|
+
.catch((error) => this.rejectReplicaDirective(error));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
void ready.catch((error) => this.rejectReplicaDirective(error));
|
|
334
423
|
}
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (message.type === "session.scope") {
|
|
338
|
-
this.installQueryCacheDirective(message.queryCache);
|
|
339
|
-
this.queryCacheNegotiatedSocketGeneration = this.socketGeneration;
|
|
340
|
-
this.resumeQuerySubscriptions();
|
|
341
424
|
return;
|
|
342
425
|
}
|
|
343
426
|
if (message.type === "auth.result" || message.type === "auth.error") {
|
|
@@ -347,10 +430,37 @@ export class GonvexClient {
|
|
|
347
430
|
this.authWatchdogTimer = undefined;
|
|
348
431
|
}
|
|
349
432
|
if (message.type === "auth.result") {
|
|
433
|
+
const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
|
|
434
|
+
if (developerSessionToken) {
|
|
435
|
+
// The activation token is single-use. Keep its rotating successor
|
|
436
|
+
// only in process memory and use it for the next reconnect.
|
|
437
|
+
this.auth = { ...this.auth, token: developerSessionToken, fetchToken: undefined };
|
|
438
|
+
}
|
|
350
439
|
this.authRetriedAfterError = false;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
440
|
+
const directive = replicaDirectiveFromAuthResult(message.result);
|
|
441
|
+
if (!directive) {
|
|
442
|
+
if (!this.auth.tenant) {
|
|
443
|
+
this.resumeQuerySubscriptions();
|
|
444
|
+
this.settleManagedAuthAttempt(message.id);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
this.settleManagedAuthAttempt(message.id, "Runtime did not provide an authoritative Local Replica visibility scope");
|
|
448
|
+
this.rejectMissingReplicaDirective();
|
|
449
|
+
}
|
|
450
|
+
this.flushPendingMessages();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
void this.activateReplicaDirective(directive)
|
|
454
|
+
.then(() => this.activateOutboxScope())
|
|
455
|
+
.then(() => {
|
|
456
|
+
this.resumeQuerySubscriptions();
|
|
457
|
+
this.resumeReplicaSubscriptions();
|
|
458
|
+
this.settleManagedAuthAttempt(message.id);
|
|
459
|
+
})
|
|
460
|
+
.catch((error) => {
|
|
461
|
+
this.settleManagedAuthAttempt(message.id, error instanceof Error ? error.message : "Runtime returned an invalid Local Replica scope");
|
|
462
|
+
this.rejectReplicaDirective(error);
|
|
463
|
+
});
|
|
354
464
|
}
|
|
355
465
|
else {
|
|
356
466
|
const fetcher = this.auth.fetchToken;
|
|
@@ -365,24 +475,55 @@ export class GonvexClient {
|
|
|
365
475
|
return;
|
|
366
476
|
}
|
|
367
477
|
this.authRetriedAfterError = false;
|
|
368
|
-
this.
|
|
478
|
+
this.quarantineReplicaScope();
|
|
479
|
+
this.settleManagedAuthAttempt(message.id, message.error);
|
|
369
480
|
this.notifyAuthError(message.error);
|
|
370
481
|
}
|
|
371
482
|
this.flushPendingMessages();
|
|
372
483
|
}
|
|
373
|
-
if (message.type === "
|
|
484
|
+
if (message.type === "replica.readyMany") {
|
|
374
485
|
for (const ready of message.ready) {
|
|
375
|
-
const readyMessage = { type: "
|
|
486
|
+
const readyMessage = { type: "replica.ready", ...ready };
|
|
376
487
|
this.handlers.get(ready.id)?.(readyMessage);
|
|
377
488
|
}
|
|
378
489
|
return;
|
|
379
490
|
}
|
|
380
|
-
if (message.type === "
|
|
381
|
-
|
|
382
|
-
|
|
491
|
+
if (message.type === "replica.transaction") {
|
|
492
|
+
// Replica frames carry no tenant/scope field. During auth renewal we
|
|
493
|
+
// cannot safely attribute a late frame to either side of the switch.
|
|
494
|
+
if (this.authInFlight)
|
|
495
|
+
return;
|
|
496
|
+
const scope = this.replicaScope;
|
|
497
|
+
void this.replica.applyTransaction({
|
|
498
|
+
cursor: message.cursor,
|
|
499
|
+
originCommandId: message.originCommandId,
|
|
500
|
+
changes: message.changes.map((change) => ({
|
|
501
|
+
...change,
|
|
502
|
+
oldValue: asReplicaRow(change.oldValue),
|
|
503
|
+
newValue: asReplicaRow(change.newValue),
|
|
504
|
+
})),
|
|
505
|
+
}, scope).then(() => {
|
|
506
|
+
if (message.originCommandId && !this.replica.hasPendingCommand(message.originCommandId)) {
|
|
507
|
+
void this.ackOptimisticReducer(message.originCommandId);
|
|
508
|
+
}
|
|
509
|
+
}).catch(() => this.replica.setFreshness("verifying"));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (message.type === "replica.watermark") {
|
|
513
|
+
if (this.serverCapabilities.replicaWatermark === 1) {
|
|
514
|
+
this.handleReplicaWatermark(message.revision);
|
|
383
515
|
}
|
|
384
516
|
return;
|
|
385
517
|
}
|
|
518
|
+
if (message.type === "support.command") {
|
|
519
|
+
const result = message.result && typeof message.result === "object" && !Array.isArray(message.result)
|
|
520
|
+
? message.result
|
|
521
|
+
: {};
|
|
522
|
+
const command = { id: message.id, kind: String(result.kind ?? ""), payload: result.payload ?? null };
|
|
523
|
+
for (const handler of this.supportCommandHandlers)
|
|
524
|
+
handler(command);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
386
527
|
if (message.type === "query.fanout") {
|
|
387
528
|
const { ids, queryType, ...shared } = message;
|
|
388
529
|
for (const id of ids) {
|
|
@@ -412,8 +553,9 @@ export class GonvexClient {
|
|
|
412
553
|
}
|
|
413
554
|
close() {
|
|
414
555
|
this.manuallyClosed = true;
|
|
556
|
+
this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
|
|
415
557
|
if (isEphemeralOutboxScope(this.outboxScope)) {
|
|
416
|
-
void this.
|
|
558
|
+
void this.reducerOutbox.clear(this.outboxScope);
|
|
417
559
|
}
|
|
418
560
|
if (this.reconnectTimer) {
|
|
419
561
|
clearTimeout(this.reconnectTimer);
|
|
@@ -426,21 +568,16 @@ export class GonvexClient {
|
|
|
426
568
|
}
|
|
427
569
|
this.oneShotQueries.clear();
|
|
428
570
|
this.rejectPendingCalls((call) => new GonvexClientError(`Gonvex client was closed while waiting for ${call.kind} ${call.path}`, { code: "closed", path: call.path, operation: call.kind }));
|
|
429
|
-
for (const subscription of this.
|
|
430
|
-
this.
|
|
571
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
572
|
+
this.clearReplicaRetry(subscription);
|
|
431
573
|
if (subscription.unsubscribeTimer)
|
|
432
574
|
clearTimeout(subscription.unsubscribeTimer);
|
|
433
|
-
if (subscription.watermarkPersistTimer) {
|
|
434
|
-
clearTimeout(subscription.watermarkPersistTimer);
|
|
435
|
-
subscription.watermarkPersistTimer = undefined;
|
|
436
|
-
this.persistSyncSnapshot(subscription, true);
|
|
437
|
-
}
|
|
438
575
|
}
|
|
439
|
-
if (this.
|
|
440
|
-
clearTimeout(this.
|
|
441
|
-
this.
|
|
576
|
+
if (this.replicaOpenFlushTimer) {
|
|
577
|
+
clearTimeout(this.replicaOpenFlushTimer);
|
|
578
|
+
this.replicaOpenFlushTimer = undefined;
|
|
442
579
|
}
|
|
443
|
-
this.
|
|
580
|
+
this.pendingReplicaOpens.clear();
|
|
444
581
|
if (this.querySubscribeFlushTimer) {
|
|
445
582
|
clearTimeout(this.querySubscribeFlushTimer);
|
|
446
583
|
this.querySubscribeFlushTimer = undefined;
|
|
@@ -451,39 +588,32 @@ export class GonvexClient {
|
|
|
451
588
|
this.outboxDrainTimer = undefined;
|
|
452
589
|
}
|
|
453
590
|
this.unsubscribeOutbox();
|
|
454
|
-
this.unsubscribeOverlay();
|
|
455
|
-
for (const subscription of this.querySubscriptions.values()) {
|
|
456
|
-
if (subscription.cacheReadFallbackTimer)
|
|
457
|
-
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
458
|
-
}
|
|
459
591
|
this.handlers.clear();
|
|
460
592
|
this.querySubscriptions.clear();
|
|
461
|
-
this.
|
|
593
|
+
this.replicaSubscriptions.clear();
|
|
462
594
|
this.sessionScopeHandlers.clear();
|
|
463
595
|
this.authErrorHandlers.clear();
|
|
464
596
|
// Invalidate any token fetch still in flight so its resolve can't touch
|
|
465
597
|
// the closed client's caches.
|
|
466
598
|
this.authFetchGeneration += 1;
|
|
467
|
-
this.queryCacheGeneration += 1;
|
|
468
|
-
this.queryCacheDirective = undefined;
|
|
469
|
-
this.queryCache?.close();
|
|
470
|
-
this.syncStore?.close();
|
|
471
599
|
this.errorReporter?.close();
|
|
472
600
|
const socket = this.socket;
|
|
473
601
|
this.socket = undefined;
|
|
474
602
|
this.isWebSocketConnected = false;
|
|
603
|
+
this.replica.setFreshness("offline");
|
|
475
604
|
this.notifyConnectionState();
|
|
476
605
|
this.connectionStateHandlers.clear();
|
|
606
|
+
this.supportCommandHandlers.clear();
|
|
477
607
|
if (!socket)
|
|
478
608
|
return;
|
|
479
609
|
socket.close();
|
|
480
610
|
}
|
|
481
|
-
rejectPendingCalls(makeError) {
|
|
611
|
+
rejectPendingCalls(makeError, predicate = () => true) {
|
|
482
612
|
if (this.pendingCalls.size === 0)
|
|
483
613
|
return;
|
|
484
|
-
const calls = Array.from(this.pendingCalls.values());
|
|
485
|
-
this.pendingCalls.clear();
|
|
614
|
+
const calls = Array.from(this.pendingCalls.values()).filter(predicate);
|
|
486
615
|
for (const call of calls) {
|
|
616
|
+
this.pendingCalls.delete(call.id);
|
|
487
617
|
if (call.timeoutTimer)
|
|
488
618
|
clearTimeout(call.timeoutTimer);
|
|
489
619
|
this.handlers.delete(call.id);
|
|
@@ -498,23 +628,15 @@ export class GonvexClient {
|
|
|
498
628
|
this.sessionScopeHandlers.add(handler);
|
|
499
629
|
return () => this.sessionScopeHandlers.delete(handler);
|
|
500
630
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const scope = options.allScopes ? undefined : this.queryCacheDirective?.scope;
|
|
505
|
-
if (!options.allScopes && !scope)
|
|
506
|
-
return;
|
|
507
|
-
await this.queryCache.clear(scope);
|
|
508
|
-
}
|
|
509
|
-
getQueryCacheStatus() {
|
|
510
|
-
return this.queryCache?.status() ?? {
|
|
511
|
-
enabled: false,
|
|
512
|
-
readsEnabled: false,
|
|
513
|
-
writesEnabled: false,
|
|
514
|
-
reason: "disabled-by-client",
|
|
515
|
-
};
|
|
631
|
+
onSupportCommand(handler) {
|
|
632
|
+
this.supportCommandHandlers.add(handler);
|
|
633
|
+
return () => this.supportCommandHandlers.delete(handler);
|
|
516
634
|
}
|
|
517
|
-
|
|
635
|
+
subscribeLiveQuery(ref, args = {}, onMessage) {
|
|
636
|
+
const isControlLiveQuery = ref.scope === "control" && ref.delivery === "live";
|
|
637
|
+
if ((!ref.live?.plan || ref.delivery !== "live") && !isControlLiveQuery) {
|
|
638
|
+
throw new GonvexClientError(`Query ${ref.path} is not a structured Live Query`, { code: "server", path: ref.path, operation: "query" });
|
|
639
|
+
}
|
|
518
640
|
this.connect();
|
|
519
641
|
const key = querySubscriptionKey(ref, args);
|
|
520
642
|
const existing = this.querySubscriptions.get(key);
|
|
@@ -525,12 +647,11 @@ export class GonvexClient {
|
|
|
525
647
|
existing.unsubscribeTimer = undefined;
|
|
526
648
|
}
|
|
527
649
|
existing.listeners.add(onMessage);
|
|
528
|
-
this.startQueryCacheRead(existing);
|
|
529
650
|
// Replay the latest result/error to this late joiner. Coalesced subscriptions
|
|
530
651
|
// share a single server subscription, so the server only sends `initial` once —
|
|
531
652
|
// to the first subscriber. Without this replay, components that mount after the
|
|
532
653
|
// initial result arrives (e.g. a dialog opened later) would never receive data
|
|
533
|
-
// until the next
|
|
654
|
+
// until the next committed change. Replaying here (not via the shared
|
|
534
655
|
// handler) keeps the cached value flowing without emitting extra telemetry/traffic.
|
|
535
656
|
const cached = existing.lastMessage;
|
|
536
657
|
if (wasOrphaned && cached?.type === "query.error") {
|
|
@@ -556,66 +677,142 @@ export class GonvexClient {
|
|
|
556
677
|
id: randomID(),
|
|
557
678
|
key,
|
|
558
679
|
path: ref.path,
|
|
559
|
-
|
|
680
|
+
live: ref.live ? { ...ref.live, resultPath: [...(ref.live.resultPath ?? [])] } : undefined,
|
|
560
681
|
args,
|
|
561
682
|
listeners: new Set([onMessage]),
|
|
562
683
|
serverSettled: false,
|
|
684
|
+
scope: this.replicaScope,
|
|
685
|
+
executionScope: ref.scope ?? "tenant",
|
|
563
686
|
};
|
|
564
|
-
if (subscription.projection) {
|
|
565
|
-
this.overlay.expectSource(subscription.key, subscription.projection.entity);
|
|
566
|
-
}
|
|
567
687
|
this.querySubscriptions.set(key, subscription);
|
|
568
688
|
this.handlers.set(subscription.id, (message) => {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
689
|
+
const scope = subscription.scope ?? this.replicaScope;
|
|
690
|
+
void this.handleQueryMessage(subscription, message, scope).catch(() => this.replica.setFreshness("verifying"));
|
|
691
|
+
});
|
|
692
|
+
this.sendSubscription(subscription);
|
|
693
|
+
return () => this.unsubscribeQueryListener(key, onMessage);
|
|
694
|
+
}
|
|
695
|
+
/** Watch an authorized host-owned Control Plane Query on the persistent connection. */
|
|
696
|
+
watchControlQuery(ref, args = {}) {
|
|
697
|
+
if (ref.scope !== "control" || ref.kind !== "query" || ref.delivery !== "live") {
|
|
698
|
+
throw new GonvexClientError(`Query ${ref.path} is not a live Control Plane Query`, { code: "server", path: ref.path, operation: "query" });
|
|
699
|
+
}
|
|
700
|
+
const listeners = new Set();
|
|
701
|
+
let result;
|
|
702
|
+
let error;
|
|
703
|
+
let version = 0;
|
|
704
|
+
let snapshot = { result, version };
|
|
705
|
+
const notify = () => queueMicrotask(() => listeners.forEach((listener) => listener()));
|
|
706
|
+
const unsubscribe = this.subscribeLiveQuery(ref, args, (message) => {
|
|
573
707
|
if (message.type === "query.result") {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
this.recordTelemetry({
|
|
580
|
-
type: "query",
|
|
581
|
-
id: message.id,
|
|
582
|
-
path: subscription.path,
|
|
583
|
-
reason: message.reason,
|
|
584
|
-
outcome: "ok",
|
|
585
|
-
clientReceivedAtMs: nowMs(),
|
|
586
|
-
serverTrace: message.trace,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
if (message.type === "query.error") {
|
|
590
|
-
subscription.serverSettled = true;
|
|
591
|
-
subscription.lastMessage = message;
|
|
592
|
-
this.recordTelemetry({
|
|
593
|
-
type: "query",
|
|
594
|
-
id: message.id,
|
|
595
|
-
path: subscription.path,
|
|
596
|
-
outcome: "error",
|
|
597
|
-
error: message.error,
|
|
598
|
-
clientReceivedAtMs: nowMs(),
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
const outgoing = this.materializeQueryMessage(subscription, message);
|
|
602
|
-
for (const listener of Array.from(subscription.listeners)) {
|
|
603
|
-
listener(outgoing);
|
|
708
|
+
result = message.result;
|
|
709
|
+
error = undefined;
|
|
710
|
+
version += 1;
|
|
711
|
+
snapshot = { result, version };
|
|
712
|
+
notify();
|
|
604
713
|
}
|
|
605
|
-
if (message.type === "query.
|
|
606
|
-
|
|
607
|
-
|
|
714
|
+
else if (message.type === "query.error") {
|
|
715
|
+
error = new GonvexClientError(message.error, { code: "server", path: ref.path, operation: "query" });
|
|
716
|
+
version += 1;
|
|
717
|
+
snapshot = { result, version };
|
|
718
|
+
notify();
|
|
608
719
|
}
|
|
609
|
-
|
|
610
|
-
|
|
720
|
+
});
|
|
721
|
+
return {
|
|
722
|
+
getSnapshot() {
|
|
723
|
+
if (error)
|
|
724
|
+
throw error;
|
|
725
|
+
return snapshot;
|
|
726
|
+
},
|
|
727
|
+
onUpdate(listener) {
|
|
728
|
+
listeners.add(listener);
|
|
729
|
+
queueMicrotask(() => listeners.has(listener) && listener());
|
|
730
|
+
return () => {
|
|
731
|
+
listeners.delete(listener);
|
|
732
|
+
if (listeners.size === 0)
|
|
733
|
+
unsubscribe();
|
|
734
|
+
};
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
async handleQueryMessage(subscription, message, scope = this.replicaScope) {
|
|
739
|
+
if (scope !== this.replicaScope || (subscription.scope !== undefined && subscription.scope !== scope))
|
|
740
|
+
return;
|
|
741
|
+
const normalized = this.normalizeSubscriptionMessage(subscription, message);
|
|
742
|
+
if (!normalized)
|
|
743
|
+
return;
|
|
744
|
+
message = normalized;
|
|
745
|
+
if (message.type === "query.result") {
|
|
746
|
+
subscription.serverSettled = true;
|
|
747
|
+
subscription.lastMessage = message;
|
|
748
|
+
this.recordTelemetry({
|
|
749
|
+
type: "query",
|
|
750
|
+
id: message.id,
|
|
751
|
+
path: subscription.path,
|
|
752
|
+
reason: message.reason,
|
|
753
|
+
outcome: "ok",
|
|
754
|
+
clientReceivedAtMs: nowMs(),
|
|
755
|
+
serverTrace: message.trace,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
if (message.type === "query.error") {
|
|
759
|
+
subscription.serverSettled = true;
|
|
760
|
+
subscription.lastMessage = message;
|
|
761
|
+
this.recordTelemetry({
|
|
762
|
+
type: "query",
|
|
763
|
+
id: message.id,
|
|
764
|
+
path: subscription.path,
|
|
765
|
+
outcome: "error",
|
|
766
|
+
error: message.error,
|
|
767
|
+
clientReceivedAtMs: nowMs(),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
if (message.type === "query.result" && subscription.live) {
|
|
771
|
+
// Local Replica is the source of truth for normalized Live Query
|
|
772
|
+
// reads. Publish the query callback only after its entity/membership
|
|
773
|
+
// window has been durably committed and atomically swapped.
|
|
774
|
+
try {
|
|
775
|
+
await this.materializeLiveQuery(subscription, message, scope);
|
|
611
776
|
}
|
|
612
|
-
|
|
613
|
-
this.
|
|
777
|
+
catch {
|
|
778
|
+
this.replica.setFreshness("verifying");
|
|
614
779
|
}
|
|
780
|
+
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (message.type === "query.result") {
|
|
784
|
+
this.acknowledgeOptimisticSource(subscription.key, message.originCommandIds);
|
|
785
|
+
this.acknowledgeOptimisticQuerySnapshot(subscription, message.result);
|
|
786
|
+
}
|
|
787
|
+
const outgoing = this.materializeQueryMessage(subscription, message);
|
|
788
|
+
for (const listener of Array.from(subscription.listeners)) {
|
|
789
|
+
listener(outgoing);
|
|
790
|
+
}
|
|
791
|
+
// One-shot results are transient. Live rows are persisted by LocalReplica.
|
|
792
|
+
}
|
|
793
|
+
materializeLiveQuery(subscription, message, scope = this.replicaScope) {
|
|
794
|
+
const live = subscription.live;
|
|
795
|
+
if (!live)
|
|
796
|
+
return Promise.resolve();
|
|
797
|
+
const projected = rowsAtPath(message.result, live.resultPath);
|
|
798
|
+
if (!projected)
|
|
799
|
+
return Promise.resolve();
|
|
800
|
+
const rows = projected.rows.filter((row) => (row !== null && typeof row === "object" && !Array.isArray(row)));
|
|
801
|
+
return this.replica.materializeWindow({
|
|
802
|
+
signature: subscription.key,
|
|
803
|
+
kind: "live",
|
|
804
|
+
entity: live.entity,
|
|
805
|
+
key: live.key,
|
|
806
|
+
rows,
|
|
807
|
+
completeness: "complete",
|
|
808
|
+
source: message.subscriptionRevision ? "server" : "cache",
|
|
809
|
+
resultSkeleton: replaceRowsAtPath(message.result, live.resultPath, [], projected.scalar),
|
|
810
|
+
resultPath: [...live.resultPath],
|
|
811
|
+
scalar: projected.scalar,
|
|
812
|
+
windowRevision: message.windowRevision,
|
|
813
|
+
subscriptionRevision: message.subscriptionRevision,
|
|
814
|
+
scope,
|
|
615
815
|
});
|
|
616
|
-
this.sendSubscription(subscription);
|
|
617
|
-
this.startQueryCacheRead(subscription);
|
|
618
|
-
return () => this.unsubscribeQueryListener(key, onMessage);
|
|
619
816
|
}
|
|
620
817
|
normalizeSubscriptionMessage(subscription, message) {
|
|
621
818
|
if (message.type === "query.progress") {
|
|
@@ -631,7 +828,7 @@ export class GonvexClient {
|
|
|
631
828
|
subscription.lastRevision = message.throughRevision;
|
|
632
829
|
subscription.revisionSocketGeneration = this.socketGeneration;
|
|
633
830
|
subscription.serverSettled = true;
|
|
634
|
-
this.acknowledgeOptimisticSource(subscription.key, message.
|
|
831
|
+
this.acknowledgeOptimisticSource(subscription.key, message.originCommandIds);
|
|
635
832
|
// Progress advances freshness without waking React/query listeners.
|
|
636
833
|
return undefined;
|
|
637
834
|
}
|
|
@@ -663,10 +860,10 @@ export class GonvexClient {
|
|
|
663
860
|
result,
|
|
664
861
|
reason: message.reason,
|
|
665
862
|
trace: message.trace,
|
|
666
|
-
|
|
667
|
-
|
|
863
|
+
replicaScope: message.replicaScope,
|
|
864
|
+
windowRevision: message.windowRevision,
|
|
668
865
|
subscriptionRevision: message.subscriptionRevision,
|
|
669
|
-
|
|
866
|
+
originCommandIds: message.originCommandIds,
|
|
670
867
|
};
|
|
671
868
|
}
|
|
672
869
|
if (message.type === "query.pagePatch") {
|
|
@@ -689,7 +886,7 @@ export class GonvexClient {
|
|
|
689
886
|
const metadata = isJsonRecord(message.result) ? message.result : {};
|
|
690
887
|
subscription.lastRevision = message.subscriptionRevision;
|
|
691
888
|
subscription.revisionSocketGeneration = this.socketGeneration;
|
|
692
|
-
return { ...message, type: "query.result", result: { ...previous.result, ...metadata, page },
|
|
889
|
+
return { ...message, type: "query.result", result: { ...previous.result, ...metadata, page }, originCommandIds: message.originCommandIds };
|
|
693
890
|
}
|
|
694
891
|
if (message.type === "query.objectPatch") {
|
|
695
892
|
if (!sameRevision(message.baseRevision, subscription.lastRevision)) {
|
|
@@ -719,7 +916,7 @@ export class GonvexClient {
|
|
|
719
916
|
}
|
|
720
917
|
subscription.lastRevision = message.subscriptionRevision;
|
|
721
918
|
subscription.revisionSocketGeneration = this.socketGeneration;
|
|
722
|
-
return { ...message, type: "query.result", result,
|
|
919
|
+
return { ...message, type: "query.result", result, originCommandIds: message.originCommandIds };
|
|
723
920
|
}
|
|
724
921
|
if (message.type === "query.result" && message.subscriptionRevision) {
|
|
725
922
|
if (!this.acceptRevision(subscription, message.subscriptionRevision))
|
|
@@ -740,51 +937,10 @@ export class GonvexClient {
|
|
|
740
937
|
// could overwrite a result already accepted on the same connection.
|
|
741
938
|
return subscription.revisionSocketGeneration !== this.socketGeneration;
|
|
742
939
|
}
|
|
743
|
-
|
|
744
|
-
let latest;
|
|
745
|
-
let latestError;
|
|
746
|
-
const updateHandlers = new Set();
|
|
747
|
-
const unsubscribe = this.subscribeQuery(ref, args, (message) => {
|
|
748
|
-
if (message.type === "query.result") {
|
|
749
|
-
latest = message.result;
|
|
750
|
-
latestError = undefined;
|
|
751
|
-
for (const handler of updateHandlers)
|
|
752
|
-
handler();
|
|
753
|
-
}
|
|
754
|
-
if (message.type === "query.error") {
|
|
755
|
-
latestError = new Error(message.error);
|
|
756
|
-
for (const handler of updateHandlers)
|
|
757
|
-
handler();
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
761
|
-
latest = undefined;
|
|
762
|
-
latestError = undefined;
|
|
763
|
-
for (const handler of updateHandlers)
|
|
764
|
-
handler();
|
|
765
|
-
});
|
|
766
|
-
return {
|
|
767
|
-
localQueryResult() {
|
|
768
|
-
if (latestError)
|
|
769
|
-
throw latestError;
|
|
770
|
-
return latest;
|
|
771
|
-
},
|
|
772
|
-
onUpdate(handler) {
|
|
773
|
-
updateHandlers.add(handler);
|
|
774
|
-
return () => {
|
|
775
|
-
updateHandlers.delete(handler);
|
|
776
|
-
if (updateHandlers.size === 0) {
|
|
777
|
-
unsubscribe();
|
|
778
|
-
unsubscribeScope();
|
|
779
|
-
}
|
|
780
|
-
};
|
|
781
|
-
},
|
|
782
|
-
};
|
|
783
|
-
}
|
|
784
|
-
subscribeSync(ref, args = {}, onMessage) {
|
|
940
|
+
subscribeReplicaTransport(ref, args = {}, onMessage) {
|
|
785
941
|
this.connect();
|
|
786
942
|
const key = querySubscriptionKey(ref, args);
|
|
787
|
-
const existing = this.
|
|
943
|
+
const existing = this.replicaSubscriptions.get(key);
|
|
788
944
|
if (existing) {
|
|
789
945
|
if (existing.unsubscribeTimer) {
|
|
790
946
|
clearTimeout(existing.unsubscribeTimer);
|
|
@@ -794,694 +950,609 @@ export class GonvexClient {
|
|
|
794
950
|
if (existing.lastMessage) {
|
|
795
951
|
queueMicrotask(() => {
|
|
796
952
|
if (existing.listeners.has(onMessage) && existing.lastMessage) {
|
|
797
|
-
onMessage(this.
|
|
953
|
+
onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
|
|
798
954
|
}
|
|
799
955
|
});
|
|
800
956
|
}
|
|
801
|
-
return () => this.
|
|
957
|
+
return () => this.unsubscribeReplicaListener(key, onMessage);
|
|
802
958
|
}
|
|
803
959
|
const subscription = {
|
|
804
960
|
id: randomID(),
|
|
805
961
|
key,
|
|
806
962
|
path: ref.path,
|
|
807
|
-
entity: ref.
|
|
963
|
+
entity: ref.live?.entity ?? ref.path,
|
|
808
964
|
args,
|
|
809
965
|
listeners: new Set([onMessage]),
|
|
810
|
-
|
|
811
|
-
keyField: "id",
|
|
966
|
+
scope: this.replicaScope,
|
|
812
967
|
opening: false,
|
|
813
|
-
persistence: Promise.resolve(),
|
|
814
968
|
retryAttempt: 0,
|
|
815
969
|
isUpToDate: false,
|
|
816
|
-
hashes: {},
|
|
817
970
|
forceFullIntegrity: false,
|
|
818
971
|
verificationGeneration: 0,
|
|
819
972
|
retiredEpochs: new Set(),
|
|
820
973
|
};
|
|
821
|
-
this.
|
|
822
|
-
this.
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
974
|
+
this.replicaSubscriptions.set(key, subscription);
|
|
975
|
+
this.handlers.set(subscription.id, (message) => {
|
|
976
|
+
const scope = subscription.scope ?? this.replicaScope;
|
|
977
|
+
void this.handleReplicaMessage(subscription, message, scope)
|
|
978
|
+
.catch(() => this.replica.setFreshness("verifying"));
|
|
979
|
+
});
|
|
980
|
+
this.startReplica(subscription);
|
|
981
|
+
return () => this.unsubscribeReplicaListener(key, onMessage);
|
|
982
|
+
}
|
|
983
|
+
/** Subscribe to a bounded Replica Collection. */
|
|
984
|
+
subscribeReplica(ref, args = {}, onMessage) {
|
|
985
|
+
return this.subscribeReplicaTransport(ref, args, onMessage);
|
|
986
|
+
}
|
|
987
|
+
/** Watch a bounded Replica Collection through the normalized Local Replica. */
|
|
988
|
+
watchReplica(ref, args = {}) {
|
|
831
989
|
const key = querySubscriptionKey(ref, args);
|
|
832
990
|
const updateHandlers = new Set();
|
|
991
|
+
let latestError;
|
|
992
|
+
let snapshotVersion = -1;
|
|
993
|
+
let snapshotRows;
|
|
994
|
+
let stateVersion = -1;
|
|
995
|
+
let snapshotState;
|
|
996
|
+
let releaseTimer;
|
|
833
997
|
const notify = () => {
|
|
834
998
|
for (const handler of updateHandlers)
|
|
835
999
|
handler();
|
|
836
1000
|
};
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1001
|
+
// Keep the ReplicaSubscription as transport/reconciliation state only. The
|
|
1002
|
+
// value returned by this watch always comes from normalized LocalReplica.
|
|
1003
|
+
const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
|
|
1004
|
+
if (message.type === "replica.error") {
|
|
1005
|
+
latestError = new Error(message.error);
|
|
841
1006
|
notify();
|
|
842
1007
|
}
|
|
843
|
-
else if (message.type === "
|
|
1008
|
+
else if (message.type === "replica.syncing" || message.type === "replica.reset") {
|
|
844
1009
|
latestError = undefined;
|
|
845
1010
|
notify();
|
|
846
1011
|
}
|
|
847
|
-
else if (message.type === "
|
|
1012
|
+
else if (message.type === "replica.snapshot" || message.type === "replica.ready") {
|
|
1013
|
+
latestError = undefined;
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
const unsubscribeReplica = this.replica.subscribe(notify);
|
|
1017
|
+
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
1018
|
+
latestError = undefined;
|
|
1019
|
+
notify();
|
|
1020
|
+
});
|
|
1021
|
+
return {
|
|
1022
|
+
localReplicaResult: () => {
|
|
1023
|
+
if (latestError)
|
|
1024
|
+
throw latestError;
|
|
1025
|
+
if (!this.replica.hasLiveQuery(key))
|
|
1026
|
+
return undefined;
|
|
1027
|
+
const version = this.replica.version();
|
|
1028
|
+
if (snapshotVersion === version)
|
|
1029
|
+
return snapshotRows;
|
|
1030
|
+
snapshotVersion = version;
|
|
1031
|
+
snapshotRows = this.replica.liveQuery(key).rows;
|
|
1032
|
+
return snapshotRows;
|
|
1033
|
+
},
|
|
1034
|
+
localReplicaState: () => {
|
|
1035
|
+
if (latestError)
|
|
1036
|
+
throw latestError;
|
|
1037
|
+
if (!this.replica.hasLiveQuery(key))
|
|
1038
|
+
return undefined;
|
|
1039
|
+
const version = this.replica.version();
|
|
1040
|
+
if (stateVersion === version)
|
|
1041
|
+
return snapshotState;
|
|
1042
|
+
stateVersion = version;
|
|
1043
|
+
snapshotState = this.replica.collectionState(key);
|
|
1044
|
+
return snapshotState;
|
|
1045
|
+
},
|
|
1046
|
+
status: () => ({
|
|
1047
|
+
isLoading: !this.replica.hasLiveQuery(key),
|
|
1048
|
+
isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
|
|
1049
|
+
}),
|
|
1050
|
+
onUpdate(handler) {
|
|
1051
|
+
if (releaseTimer) {
|
|
1052
|
+
clearTimeout(releaseTimer);
|
|
1053
|
+
releaseTimer = undefined;
|
|
1054
|
+
}
|
|
1055
|
+
updateHandlers.add(handler);
|
|
1056
|
+
queueMicrotask(() => {
|
|
1057
|
+
if (updateHandlers.has(handler))
|
|
1058
|
+
handler();
|
|
1059
|
+
});
|
|
1060
|
+
return () => {
|
|
1061
|
+
updateHandlers.delete(handler);
|
|
1062
|
+
if (updateHandlers.size > 0 || releaseTimer)
|
|
1063
|
+
return;
|
|
1064
|
+
releaseTimer = setTimeout(() => {
|
|
1065
|
+
releaseTimer = undefined;
|
|
1066
|
+
if (updateHandlers.size > 0)
|
|
1067
|
+
return;
|
|
1068
|
+
unsubscribeTransport();
|
|
1069
|
+
unsubscribeReplica();
|
|
1070
|
+
unsubscribeScope();
|
|
1071
|
+
}, 0);
|
|
1072
|
+
};
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Watch a structured Live Query through normalized LocalReplica rows. The
|
|
1078
|
+
* latest query result is retained only as the transport-shaped skeleton;
|
|
1079
|
+
* its row window is always rebuilt from LocalReplica membership/entities.
|
|
1080
|
+
*/
|
|
1081
|
+
watchLiveQuery(ref, args = {}) {
|
|
1082
|
+
const key = querySubscriptionKey(ref, args);
|
|
1083
|
+
const updateHandlers = new Set();
|
|
1084
|
+
let transportResult;
|
|
1085
|
+
let transportGeneration = 0;
|
|
1086
|
+
let snapshotToken = "";
|
|
1087
|
+
let snapshotResult;
|
|
1088
|
+
let latestError;
|
|
1089
|
+
let notifyQueued = false;
|
|
1090
|
+
let releaseTimer;
|
|
1091
|
+
const notify = () => {
|
|
1092
|
+
if (notifyQueued)
|
|
1093
|
+
return;
|
|
1094
|
+
notifyQueued = true;
|
|
1095
|
+
queueMicrotask(() => {
|
|
1096
|
+
notifyQueued = false;
|
|
1097
|
+
for (const handler of updateHandlers)
|
|
1098
|
+
handler();
|
|
1099
|
+
});
|
|
1100
|
+
};
|
|
1101
|
+
const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
|
|
1102
|
+
if (message.type === "query.result") {
|
|
1103
|
+
transportResult = message.result;
|
|
1104
|
+
transportGeneration += 1;
|
|
1105
|
+
latestError = undefined;
|
|
1106
|
+
// LocalReplica has already published its atomic window swap before
|
|
1107
|
+
// this callback is emitted, so this is the single initial UI wake-up.
|
|
848
1108
|
notify();
|
|
849
1109
|
}
|
|
850
|
-
else if (message.type === "
|
|
851
|
-
latestError = new
|
|
1110
|
+
else if (message.type === "query.error") {
|
|
1111
|
+
latestError = new GonvexClientError(message.error, {
|
|
1112
|
+
code: "server", path: ref.path, operation: "query",
|
|
1113
|
+
});
|
|
852
1114
|
notify();
|
|
853
1115
|
}
|
|
854
1116
|
});
|
|
1117
|
+
// During the initial query result, LocalReplica notifies before the
|
|
1118
|
+
// transport-shaped skeleton is installed above. Suppress that empty
|
|
1119
|
+
// intermediate wake-up; later transactions notify directly from the
|
|
1120
|
+
// normalized store.
|
|
1121
|
+
const unsubscribeReplica = this.replica.subscribe(() => {
|
|
1122
|
+
if (transportResult !== undefined || this.replica.hasLiveQuery(key))
|
|
1123
|
+
notify();
|
|
1124
|
+
});
|
|
1125
|
+
void this.replicaReady.then(() => notify());
|
|
855
1126
|
const unsubscribeScope = this.onSessionScopeChange(() => {
|
|
856
|
-
|
|
1127
|
+
transportResult = undefined;
|
|
1128
|
+
transportGeneration += 1;
|
|
1129
|
+
snapshotToken = "";
|
|
1130
|
+
snapshotResult = undefined;
|
|
857
1131
|
latestError = undefined;
|
|
858
1132
|
notify();
|
|
859
1133
|
});
|
|
860
1134
|
return {
|
|
861
|
-
|
|
1135
|
+
localLiveQueryResult: () => {
|
|
862
1136
|
if (latestError)
|
|
863
1137
|
throw latestError;
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1138
|
+
if (!ref.live)
|
|
1139
|
+
return undefined;
|
|
1140
|
+
const offline = this.replica.freshness() === "offline" && ref.live.plan
|
|
1141
|
+
? this.offlineLiveQuery(ref, args)
|
|
1142
|
+
: undefined;
|
|
1143
|
+
if (!this.replica.hasLiveQuery(key) && !offline?.supported)
|
|
1144
|
+
return undefined;
|
|
1145
|
+
const window = this.replica.getWindow(key);
|
|
1146
|
+
const skeleton = transportResult ?? window?.resultSkeleton;
|
|
1147
|
+
if (skeleton === undefined && (ref.live.resultPath?.length ?? 0) > 0)
|
|
1148
|
+
return undefined;
|
|
1149
|
+
const nextToken = `${this.replica.version()}:${transportGeneration}`;
|
|
1150
|
+
if (snapshotToken === nextToken)
|
|
1151
|
+
return snapshotResult;
|
|
1152
|
+
const materializedRows = offline?.supported
|
|
1153
|
+
? offline.rows
|
|
1154
|
+
: this.replica.liveQuery(key).rows;
|
|
1155
|
+
const base = skeleton ?? [];
|
|
1156
|
+
const projected = rowsAtPath(base, ref.live.resultPath ?? []);
|
|
1157
|
+
let nextResult = !projected
|
|
1158
|
+
? base
|
|
1159
|
+
: replaceRowsAtPath(base, ref.live.resultPath ?? [], materializedRows, projected.scalar);
|
|
1160
|
+
if (offline?.supported && projected) {
|
|
1161
|
+
nextResult = replaceOfflineLiveQueryMetadata(nextResult, ref.live.resultPath ?? [], offline);
|
|
1162
|
+
}
|
|
1163
|
+
snapshotResult = nextResult;
|
|
1164
|
+
snapshotToken = nextToken;
|
|
1165
|
+
return snapshotResult;
|
|
871
1166
|
},
|
|
872
1167
|
onUpdate(handler) {
|
|
1168
|
+
if (releaseTimer) {
|
|
1169
|
+
clearTimeout(releaseTimer);
|
|
1170
|
+
releaseTimer = undefined;
|
|
1171
|
+
}
|
|
873
1172
|
updateHandlers.add(handler);
|
|
1173
|
+
queueMicrotask(() => {
|
|
1174
|
+
if (updateHandlers.has(handler))
|
|
1175
|
+
handler();
|
|
1176
|
+
});
|
|
874
1177
|
return () => {
|
|
875
1178
|
updateHandlers.delete(handler);
|
|
876
|
-
if (updateHandlers.size
|
|
877
|
-
|
|
1179
|
+
if (updateHandlers.size > 0 || releaseTimer)
|
|
1180
|
+
return;
|
|
1181
|
+
releaseTimer = setTimeout(() => {
|
|
1182
|
+
releaseTimer = undefined;
|
|
1183
|
+
if (updateHandlers.size > 0)
|
|
1184
|
+
return;
|
|
1185
|
+
unsubscribeQuery();
|
|
1186
|
+
unsubscribeReplica();
|
|
878
1187
|
unsubscribeScope();
|
|
879
|
-
}
|
|
1188
|
+
}, 0);
|
|
880
1189
|
};
|
|
881
1190
|
},
|
|
882
1191
|
};
|
|
883
1192
|
}
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if (!subscription.opening)
|
|
890
|
-
return;
|
|
891
|
-
if (syncCursorIsStale(subscription, message.cursor))
|
|
892
|
-
return;
|
|
893
|
-
if (subscription.cursor
|
|
894
|
-
&& message.cursor.epoch === subscription.cursor.epoch
|
|
895
|
-
&& message.cursor.revision < subscription.cursor.revision)
|
|
1193
|
+
async handleReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1194
|
+
if (scope !== this.replicaScope || (subscription.scope !== undefined && subscription.scope !== scope))
|
|
1195
|
+
return;
|
|
1196
|
+
const current = () => this.replica.getWindow(subscription.key);
|
|
1197
|
+
if (message.type === "replica.snapshot") {
|
|
1198
|
+
if (!subscription.opening || replicaCursorIsStale(subscription, message.cursor))
|
|
896
1199
|
return;
|
|
897
|
-
this.
|
|
898
|
-
subscription.verificationGeneration += 1;
|
|
899
|
-
subscription.isUpToDate = false;
|
|
1200
|
+
this.clearReplicaRetry(subscription, true);
|
|
900
1201
|
subscription.opening = false;
|
|
901
|
-
subscription.
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1202
|
+
subscription.isUpToDate = false;
|
|
1203
|
+
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1204
|
+
const rows = boundReplicaRows(message.result, message.key, message.maxRows, message.maxBytes, message.orderBy, message.orderDirection);
|
|
1205
|
+
const window = {
|
|
1206
|
+
signature: subscription.key,
|
|
1207
|
+
kind: "replica",
|
|
1208
|
+
entity: subscription.entity,
|
|
1209
|
+
key: message.key,
|
|
1210
|
+
rows: rows.filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
|
|
1211
|
+
// A snapshot is still verifying until replica.ready supplies the
|
|
1212
|
+
// authoritative budget/truncation result.
|
|
1213
|
+
completeness: "partial",
|
|
1214
|
+
source: "server",
|
|
1215
|
+
cursor: message.cursor,
|
|
1216
|
+
mode: message.mode,
|
|
1217
|
+
orderBy: message.orderBy,
|
|
1218
|
+
orderDirection: message.orderDirection,
|
|
1219
|
+
maxRows: message.maxRows,
|
|
1220
|
+
maxBytes: message.maxBytes,
|
|
1221
|
+
hashes: message.hashes,
|
|
1222
|
+
scope,
|
|
1223
|
+
};
|
|
1224
|
+
await this.replica.replaceWindow(window);
|
|
1225
|
+
const snapshot = { ...message, result: this.replica.windowRows(subscription.key) };
|
|
916
1226
|
subscription.lastMessage = snapshot;
|
|
917
|
-
this.
|
|
918
|
-
this.persistSyncSnapshot(subscription);
|
|
1227
|
+
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
919
1228
|
return;
|
|
920
1229
|
}
|
|
921
|
-
if (message.type === "
|
|
922
|
-
|
|
1230
|
+
if (message.type === "replica.delta") {
|
|
1231
|
+
const prior = current();
|
|
1232
|
+
if (replicaCursorIsStale(subscription, message.cursor) || (prior?.cursor && message.cursor.revision < prior.cursor.revision))
|
|
923
1233
|
return;
|
|
924
|
-
|
|
925
|
-
|| message.cursor.revision < subscription.cursor.revision
|
|
926
|
-
|| (message.cursor.revision === subscription.cursor.revision
|
|
927
|
-
&& !message.digest)))
|
|
928
|
-
return;
|
|
929
|
-
this.clearSyncRetry(subscription, true);
|
|
930
|
-
subscription.verificationGeneration += 1;
|
|
1234
|
+
this.clearReplicaRetry(subscription, true);
|
|
931
1235
|
subscription.isUpToDate = false;
|
|
932
|
-
subscription
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
type: "sync.snapshot",
|
|
943
|
-
id: subscription.id,
|
|
944
|
-
path: subscription.path,
|
|
945
|
-
result: subscription.rows,
|
|
1236
|
+
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1237
|
+
await this.replica.applyWindowDelta({
|
|
1238
|
+
signature: subscription.key,
|
|
1239
|
+
kind: "replica",
|
|
1240
|
+
entity: subscription.entity,
|
|
1241
|
+
key: prior?.key ?? "id",
|
|
1242
|
+
upserts: (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
|
|
1243
|
+
deleted: message.deleted ?? [],
|
|
1244
|
+
completeness: prior?.completeness ?? "partial",
|
|
1245
|
+
source: "server",
|
|
946
1246
|
cursor: message.cursor,
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
orderBy:
|
|
950
|
-
orderDirection:
|
|
951
|
-
maxRows:
|
|
952
|
-
maxBytes:
|
|
1247
|
+
mode: prior?.mode,
|
|
1248
|
+
truncated: prior?.truncated,
|
|
1249
|
+
orderBy: prior?.orderBy,
|
|
1250
|
+
orderDirection: prior?.orderDirection,
|
|
1251
|
+
maxRows: prior?.maxRows,
|
|
1252
|
+
maxBytes: prior?.maxBytes,
|
|
1253
|
+
hashes: message.hashes ?? prior?.hashes,
|
|
1254
|
+
});
|
|
1255
|
+
const snapshot = {
|
|
1256
|
+
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1257
|
+
result: this.replica.windowRows(subscription.key), cursor: message.cursor,
|
|
1258
|
+
key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
|
|
1259
|
+
orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
|
|
953
1260
|
};
|
|
954
1261
|
subscription.lastMessage = snapshot;
|
|
955
|
-
this.
|
|
956
|
-
this.
|
|
957
|
-
this.persistSyncDelta(subscription, message.upserts ?? [], message.deleted ?? []);
|
|
1262
|
+
this.acknowledgeOptimisticSource(subscription.key, message.originCommandIds);
|
|
1263
|
+
this.emitReplicaMessage(subscription, snapshot, scope);
|
|
958
1264
|
return;
|
|
959
1265
|
}
|
|
960
|
-
if (message.type === "
|
|
961
|
-
this.
|
|
962
|
-
if (subscription.watermarkPersistTimer) {
|
|
963
|
-
clearTimeout(subscription.watermarkPersistTimer);
|
|
964
|
-
subscription.watermarkPersistTimer = undefined;
|
|
965
|
-
}
|
|
966
|
-
subscription.verificationGeneration += 1;
|
|
1266
|
+
if (message.type === "replica.reset") {
|
|
1267
|
+
this.clearReplicaRetry(subscription, true);
|
|
967
1268
|
subscription.isUpToDate = false;
|
|
968
|
-
subscription.cursor = undefined;
|
|
969
|
-
subscription.truncated = undefined;
|
|
970
|
-
subscription.rows = [];
|
|
971
|
-
subscription.persistedRows = undefined;
|
|
972
|
-
subscription.hashes = {};
|
|
973
|
-
subscription.integrityDigest = undefined;
|
|
974
|
-
subscription.integrityRows = undefined;
|
|
975
|
-
subscription.integrityEpoch = undefined;
|
|
976
|
-
subscription.forceFullIntegrity = false;
|
|
977
|
-
subscription.lastMessage = undefined;
|
|
978
1269
|
subscription.opening = false;
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
this.emitSyncMessage(subscription, message);
|
|
986
|
-
queueMicrotask(() => this.sendSyncOpen(subscription));
|
|
1270
|
+
subscription.cursorFloor = undefined;
|
|
1271
|
+
subscription.retiredEpochs.clear();
|
|
1272
|
+
subscription.lastMessage = undefined;
|
|
1273
|
+
await this.replica.removeWindow(subscription.key);
|
|
1274
|
+
this.emitReplicaMessage(subscription, message, scope);
|
|
1275
|
+
queueMicrotask(() => this.sendReplicaOpen(subscription));
|
|
987
1276
|
return;
|
|
988
1277
|
}
|
|
989
|
-
if (message.type === "
|
|
990
|
-
subscription.verificationGeneration += 1;
|
|
1278
|
+
if (message.type === "replica.syncing") {
|
|
991
1279
|
subscription.isUpToDate = false;
|
|
992
|
-
this.
|
|
1280
|
+
this.emitReplicaMessage(subscription, message, scope);
|
|
993
1281
|
return;
|
|
994
1282
|
}
|
|
995
|
-
if (message.type === "
|
|
996
|
-
subscription.verificationGeneration += 1;
|
|
1283
|
+
if (message.type === "replica.needHashes") {
|
|
997
1284
|
subscription.isUpToDate = false;
|
|
998
1285
|
subscription.opening = false;
|
|
999
|
-
subscription.
|
|
1000
|
-
this.
|
|
1001
|
-
type: "sync.syncing",
|
|
1002
|
-
id: subscription.id,
|
|
1003
|
-
path: subscription.path,
|
|
1004
|
-
reason: "integrity-reconciling",
|
|
1005
|
-
});
|
|
1006
|
-
queueMicrotask(() => this.sendSyncOpen(subscription));
|
|
1286
|
+
this.emitReplicaMessage(subscription, { type: "replica.syncing", id: subscription.id, path: subscription.path, reason: "integrity-reconciling" }, scope);
|
|
1287
|
+
queueMicrotask(() => this.sendReplicaOpen(subscription));
|
|
1007
1288
|
return;
|
|
1008
1289
|
}
|
|
1009
|
-
if (message.type === "
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|| syncCursorIsStale(subscription, message.cursor)))
|
|
1290
|
+
if (message.type === "replica.ready") {
|
|
1291
|
+
const window = current();
|
|
1292
|
+
if (!window?.cursor || replicaCursorIsStale(subscription, message.cursor) || message.cursor.revision < window.cursor.revision)
|
|
1013
1293
|
return;
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
path: subscription.path,
|
|
1020
|
-
reason: "integrity-missing",
|
|
1021
|
-
});
|
|
1294
|
+
const rows = this.replica.windowRows(subscription.key);
|
|
1295
|
+
const hashes = await replicaRowsHashes(rows, window.key);
|
|
1296
|
+
const digest = await replicaHashesDigest(hashes);
|
|
1297
|
+
if (!message.digest || message.digest !== digest) {
|
|
1298
|
+
await this.handleReplicaMessage(subscription, { type: "replica.reset", id: subscription.id, path: subscription.path, reason: "integrity-mismatch" }, scope);
|
|
1022
1299
|
return;
|
|
1023
1300
|
}
|
|
1024
|
-
|
|
1025
|
-
&& subscription.integrityRows === subscription.rows
|
|
1026
|
-
&& subscription.integrityDigest
|
|
1027
|
-
&& subscription.integrityEpoch === subscription.cursor.epoch) {
|
|
1028
|
-
if (message.digest && subscription.integrityDigest !== message.digest) {
|
|
1029
|
-
// Re-hash once before treating the server/memo disagreement as an
|
|
1030
|
-
// integrity failure. The memo may be stale even though row identity
|
|
1031
|
-
// says the collection has not changed.
|
|
1032
|
-
}
|
|
1033
|
-
else {
|
|
1034
|
-
this.acceptSyncReady(subscription, message, subscription.integrityDigest);
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
void syncRowsHashes(subscription.rows, subscription.keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ digest, hashes })))).then(({ digest, hashes }) => {
|
|
1039
|
-
if (generation !== subscription.verificationGeneration
|
|
1040
|
-
|| this.syncSubscriptions.get(subscription.key) !== subscription)
|
|
1041
|
-
return;
|
|
1042
|
-
if (message.digest && digest !== message.digest) {
|
|
1043
|
-
this.handleSyncMessage(subscription, {
|
|
1044
|
-
type: "sync.reset",
|
|
1045
|
-
id: subscription.id,
|
|
1046
|
-
path: subscription.path,
|
|
1047
|
-
reason: "integrity-mismatch",
|
|
1048
|
-
});
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
subscription.hashes = hashes;
|
|
1052
|
-
subscription.integrityDigest = digest;
|
|
1053
|
-
subscription.integrityRows = subscription.rows;
|
|
1054
|
-
this.acceptSyncReady(subscription, message, digest);
|
|
1055
|
-
}).catch(() => {
|
|
1056
|
-
if (generation !== subscription.verificationGeneration)
|
|
1057
|
-
return;
|
|
1058
|
-
this.handleSyncMessage(subscription, {
|
|
1059
|
-
type: "sync.reset",
|
|
1060
|
-
id: subscription.id,
|
|
1061
|
-
path: subscription.path,
|
|
1062
|
-
reason: "integrity-mismatch",
|
|
1063
|
-
});
|
|
1064
|
-
});
|
|
1301
|
+
await this.acceptReplicaReady(subscription, message, scope);
|
|
1065
1302
|
return;
|
|
1066
1303
|
}
|
|
1067
|
-
if (message.type === "
|
|
1068
|
-
subscription.verificationGeneration += 1;
|
|
1304
|
+
if (message.type === "replica.error") {
|
|
1069
1305
|
subscription.isUpToDate = false;
|
|
1070
1306
|
subscription.opening = false;
|
|
1071
|
-
this.
|
|
1307
|
+
this.scheduleReplicaRetry(subscription);
|
|
1072
1308
|
}
|
|
1073
|
-
this.
|
|
1309
|
+
this.emitReplicaMessage(subscription, message, scope);
|
|
1074
1310
|
}
|
|
1075
|
-
|
|
1076
|
-
this.
|
|
1311
|
+
async acceptReplicaReady(subscription, message, scope = this.replicaScope) {
|
|
1312
|
+
this.clearReplicaRetry(subscription, true);
|
|
1077
1313
|
subscription.isUpToDate = true;
|
|
1078
1314
|
subscription.opening = false;
|
|
1079
|
-
subscription
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1093
|
-
handleSyncWatermark(revision) {
|
|
1315
|
+
raiseReplicaCursorFloor(subscription, message.cursor);
|
|
1316
|
+
const window = this.replica.getWindow(subscription.key);
|
|
1317
|
+
if (window) {
|
|
1318
|
+
await this.replica.replaceWindow({
|
|
1319
|
+
...window, rows: this.replica.windowRows(subscription.key), source: "server", cursor: message.cursor,
|
|
1320
|
+
completeness: message.truncated === true ? "partial" : "complete",
|
|
1321
|
+
mode: message.mode ?? window.mode, truncated: message.truncated ?? window.truncated,
|
|
1322
|
+
hashes: window.hashes,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
this.emitReplicaMessage(subscription, message, scope);
|
|
1326
|
+
}
|
|
1327
|
+
handleReplicaWatermark(revision) {
|
|
1094
1328
|
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
1095
1329
|
return;
|
|
1096
|
-
for (const subscription of this.
|
|
1097
|
-
const cursor = subscription.cursor;
|
|
1330
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1331
|
+
const cursor = this.replica.getWindow(subscription.key)?.cursor;
|
|
1098
1332
|
if (!cursor
|
|
1099
1333
|
|| cursor.revision >= revision
|
|
1100
1334
|
|| !subscription.isUpToDate
|
|
1101
1335
|
|| subscription.opening
|
|
1102
|
-
|| subscription.
|
|
1103
|
-
|| subscription.integrityRows !== subscription.rows
|
|
1104
|
-
|| !subscription.integrityDigest
|
|
1105
|
-
|| subscription.integrityEpoch !== cursor.epoch)
|
|
1336
|
+
|| !this.replica.getWindow(subscription.key)?.hashes)
|
|
1106
1337
|
continue;
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1338
|
+
const window = this.replica.getWindow(subscription.key);
|
|
1339
|
+
if (window)
|
|
1340
|
+
void this.replica.replaceWindow({ ...window, rows: this.replica.windowRows(subscription.key), cursor: { ...cursor, revision } });
|
|
1110
1341
|
}
|
|
1111
1342
|
}
|
|
1112
|
-
|
|
1113
|
-
if (subscription.
|
|
1343
|
+
emitReplicaMessage(subscription, message, scope = this.replicaScope) {
|
|
1344
|
+
if (scope !== this.replicaScope || subscription.scope !== scope)
|
|
1114
1345
|
return;
|
|
1115
|
-
|
|
1116
|
-
subscription.watermarkPersistTimer = undefined;
|
|
1117
|
-
if (this.syncSubscriptions.get(subscription.key) !== subscription)
|
|
1118
|
-
return;
|
|
1119
|
-
this.persistSyncSnapshot(subscription, true);
|
|
1120
|
-
}, syncWatermarkPersistDelayMs);
|
|
1121
|
-
}
|
|
1122
|
-
emitSyncMessage(subscription, message) {
|
|
1123
|
-
const outgoing = this.materializeSyncMessage(subscription, message);
|
|
1346
|
+
const outgoing = this.materializeReplicaMessage(subscription, message);
|
|
1124
1347
|
for (const listener of Array.from(subscription.listeners))
|
|
1125
1348
|
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
|
-
}
|
|
1131
1349
|
}
|
|
1132
|
-
|
|
1133
|
-
if (message.type !== "
|
|
1350
|
+
materializeReplicaMessage(subscription, message) {
|
|
1351
|
+
if (message.type !== "replica.snapshot")
|
|
1134
1352
|
return message;
|
|
1135
1353
|
return {
|
|
1136
1354
|
...message,
|
|
1137
|
-
result: this.
|
|
1355
|
+
result: this.replica.windowRows(subscription.key),
|
|
1138
1356
|
};
|
|
1139
1357
|
}
|
|
1140
1358
|
materializeQueryMessage(subscription, message) {
|
|
1141
|
-
|
|
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
|
-
};
|
|
1359
|
+
return message;
|
|
1152
1360
|
}
|
|
1153
1361
|
emitOptimisticEntity(entity) {
|
|
1154
|
-
|
|
1155
|
-
if (subscription.entity !== entity)
|
|
1156
|
-
continue;
|
|
1157
|
-
this.overlay.expectSource(subscription.key, entity);
|
|
1158
|
-
if (subscription.lastMessage?.type !== "sync.snapshot")
|
|
1159
|
-
continue;
|
|
1160
|
-
this.emitSyncMessage(subscription, subscription.lastMessage);
|
|
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
|
-
}
|
|
1362
|
+
void entity;
|
|
1173
1363
|
}
|
|
1174
|
-
acknowledgeOptimisticSource(source,
|
|
1175
|
-
|
|
1176
|
-
for (const
|
|
1177
|
-
|
|
1364
|
+
acknowledgeOptimisticSource(source, originCommandIds) {
|
|
1365
|
+
void source;
|
|
1366
|
+
for (const commandId of originCommandIds ?? [])
|
|
1367
|
+
this.replica.acknowledgeCommand(commandId);
|
|
1178
1368
|
}
|
|
1179
1369
|
acknowledgeOptimisticQuerySnapshot(subscription, result) {
|
|
1180
|
-
|
|
1181
|
-
|
|
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);
|
|
1370
|
+
void subscription;
|
|
1371
|
+
void result;
|
|
1189
1372
|
}
|
|
1190
|
-
|
|
1191
|
-
for (const subscription of this.
|
|
1373
|
+
markReplicaSubscriptionsOutOfDate() {
|
|
1374
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1192
1375
|
const wasUpToDate = subscription.isUpToDate;
|
|
1193
1376
|
subscription.verificationGeneration += 1;
|
|
1194
1377
|
subscription.isUpToDate = false;
|
|
1195
1378
|
if (!wasUpToDate)
|
|
1196
1379
|
continue;
|
|
1197
|
-
this.
|
|
1198
|
-
type: "
|
|
1380
|
+
this.emitReplicaMessage(subscription, {
|
|
1381
|
+
type: "replica.syncing",
|
|
1199
1382
|
id: subscription.id,
|
|
1200
1383
|
path: subscription.path,
|
|
1201
1384
|
reason: "disconnected",
|
|
1202
1385
|
});
|
|
1203
1386
|
}
|
|
1204
1387
|
}
|
|
1205
|
-
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1388
|
+
startReplica(subscription) {
|
|
1389
|
+
const cached = this.replica.getWindow(subscription.key);
|
|
1390
|
+
if (cached) {
|
|
1391
|
+
subscription.isUpToDate = false;
|
|
1392
|
+
const message = {
|
|
1393
|
+
type: "replica.snapshot", id: subscription.id, path: subscription.path,
|
|
1394
|
+
result: this.replica.windowRows(subscription.key), cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
|
|
1395
|
+
key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
|
|
1396
|
+
orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
|
|
1397
|
+
};
|
|
1398
|
+
subscription.lastMessage = message;
|
|
1399
|
+
this.emitReplicaMessage(subscription, message, this.replicaScope);
|
|
1213
1400
|
}
|
|
1214
|
-
|
|
1215
|
-
const generation = this.syncScopeGeneration;
|
|
1216
|
-
if (subscription.cacheReadGeneration === generation)
|
|
1217
|
-
return;
|
|
1218
|
-
subscription.cacheReadGeneration = generation;
|
|
1219
|
-
// The warm read is an optimization with a deadline. If IndexedDB never
|
|
1220
|
-
// answers (a wedged Chrome origin store emits no event at all, so no
|
|
1221
|
-
// rejection ever fires), open cold after the timeout: a full snapshot
|
|
1222
|
-
// beats a permanently empty screen, and a late read result is discarded.
|
|
1223
|
-
let cacheReadSettled = false;
|
|
1224
|
-
const cacheReadTimer = setTimeout(() => {
|
|
1225
|
-
if (cacheReadSettled)
|
|
1226
|
-
return;
|
|
1227
|
-
cacheReadSettled = true;
|
|
1228
|
-
if (this.syncSubscriptions.get(subscription.key) !== subscription
|
|
1229
|
-
|| this.syncScopeGeneration !== generation)
|
|
1230
|
-
return;
|
|
1231
|
-
this.sendSyncOpen(subscription);
|
|
1232
|
-
}, syncStoreReadTimeoutMs);
|
|
1233
|
-
void store.load(scope, subscription.path, subscription.args).then((cached) => {
|
|
1234
|
-
clearTimeout(cacheReadTimer);
|
|
1235
|
-
if (cacheReadSettled)
|
|
1236
|
-
return;
|
|
1237
|
-
cacheReadSettled = true;
|
|
1238
|
-
const currentDirective = this.queryCacheDirective;
|
|
1239
|
-
if (this.syncSubscriptions.get(subscription.key) !== subscription
|
|
1240
|
-
|| this.syncScopeGeneration !== generation
|
|
1241
|
-
|| !currentDirective
|
|
1242
|
-
|| syncPersistenceScope(currentDirective) !== scope)
|
|
1243
|
-
return;
|
|
1244
|
-
if (cached) {
|
|
1245
|
-
subscription.isUpToDate = false;
|
|
1246
|
-
subscription.rows = cached.rows;
|
|
1247
|
-
// These rows came out of the store, so the store already holds them:
|
|
1248
|
-
// the ready that follows this resume must not rewrite them.
|
|
1249
|
-
subscription.persistedRows = cached.rows;
|
|
1250
|
-
subscription.cursor = cached.cursor;
|
|
1251
|
-
raiseSyncCursorFloor(subscription, cached.cursor);
|
|
1252
|
-
subscription.keyField = cached.keyField;
|
|
1253
|
-
subscription.mode = cached.mode;
|
|
1254
|
-
subscription.truncated = cached.truncated;
|
|
1255
|
-
subscription.orderBy = cached.orderBy;
|
|
1256
|
-
subscription.orderDirection = cached.orderDirection;
|
|
1257
|
-
subscription.maxRows = cached.maxRows;
|
|
1258
|
-
subscription.maxBytes = cached.maxBytes;
|
|
1259
|
-
// Stored hash metadata is never trusted. sendSyncOpen hashes these
|
|
1260
|
-
// actual materialized rows before advertising a cursor, which allows a
|
|
1261
|
-
// corrupt row to be repaired by delta without a full cache reset.
|
|
1262
|
-
subscription.hashes = {};
|
|
1263
|
-
subscription.integrityDigest = undefined;
|
|
1264
|
-
subscription.integrityRows = undefined;
|
|
1265
|
-
subscription.integrityEpoch = undefined;
|
|
1266
|
-
const message = {
|
|
1267
|
-
type: "sync.snapshot",
|
|
1268
|
-
id: subscription.id,
|
|
1269
|
-
path: subscription.path,
|
|
1270
|
-
result: cached.rows,
|
|
1271
|
-
cursor: cached.cursor,
|
|
1272
|
-
key: cached.keyField,
|
|
1273
|
-
mode: cached.mode,
|
|
1274
|
-
orderBy: cached.orderBy,
|
|
1275
|
-
orderDirection: cached.orderDirection,
|
|
1276
|
-
maxRows: cached.maxRows,
|
|
1277
|
-
maxBytes: cached.maxBytes,
|
|
1278
|
-
};
|
|
1279
|
-
subscription.lastMessage = message;
|
|
1280
|
-
this.emitSyncMessage(subscription, message);
|
|
1281
|
-
}
|
|
1282
|
-
this.sendSyncOpen(subscription);
|
|
1283
|
-
}).catch(() => {
|
|
1284
|
-
clearTimeout(cacheReadTimer);
|
|
1285
|
-
if (cacheReadSettled)
|
|
1286
|
-
return;
|
|
1287
|
-
cacheReadSettled = true;
|
|
1288
|
-
this.sendSyncOpen(subscription);
|
|
1289
|
-
});
|
|
1401
|
+
this.sendReplicaOpen(subscription);
|
|
1290
1402
|
}
|
|
1291
|
-
|
|
1403
|
+
sendReplicaOpen(subscription) {
|
|
1292
1404
|
if (subscription.listeners.size === 0 || subscription.opening)
|
|
1293
1405
|
return;
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
const rows = subscription.rows;
|
|
1297
|
-
const keyField = subscription.keyField;
|
|
1298
|
-
const socketGeneration = this.socketGeneration;
|
|
1299
|
-
void syncRowsHashes(rows, keyField).then((hashes) => (syncHashesDigest(hashes).then((digest) => ({ hashes, digest })))).then(({ hashes, digest }) => {
|
|
1300
|
-
if (this.socketGeneration !== socketGeneration
|
|
1301
|
-
|| this.syncSubscriptions.get(subscription.key) !== subscription
|
|
1302
|
-
|| subscription.listeners.size === 0
|
|
1303
|
-
|| subscription.rows !== rows
|
|
1304
|
-
|| subscription.keyField !== keyField)
|
|
1305
|
-
return;
|
|
1306
|
-
subscription.hashes = hashes;
|
|
1307
|
-
subscription.integrityDigest = digest;
|
|
1308
|
-
subscription.integrityRows = rows;
|
|
1309
|
-
subscription.integrityEpoch = subscription.cursor?.epoch;
|
|
1310
|
-
subscription.opening = false;
|
|
1311
|
-
this.sendSyncOpen(subscription);
|
|
1312
|
-
}).catch(() => {
|
|
1313
|
-
if (this.socketGeneration !== socketGeneration
|
|
1314
|
-
|| this.syncSubscriptions.get(subscription.key) !== subscription
|
|
1315
|
-
|| subscription.rows !== rows)
|
|
1316
|
-
return;
|
|
1317
|
-
subscription.opening = false;
|
|
1318
|
-
this.handleSyncMessage(subscription, {
|
|
1319
|
-
type: "sync.reset",
|
|
1320
|
-
id: subscription.id,
|
|
1321
|
-
path: subscription.path,
|
|
1322
|
-
reason: "integrity-mismatch",
|
|
1323
|
-
});
|
|
1324
|
-
});
|
|
1325
|
-
return;
|
|
1326
|
-
}
|
|
1406
|
+
const requestScope = this.replicaScope;
|
|
1407
|
+
subscription.scope = requestScope;
|
|
1327
1408
|
subscription.opening = true;
|
|
1328
1409
|
subscription.socketGeneration = this.socketGeneration;
|
|
1329
|
-
const open = this.
|
|
1330
|
-
if (this.serverCapabilities.
|
|
1331
|
-
this.
|
|
1332
|
-
if (!this.
|
|
1333
|
-
this.
|
|
1410
|
+
const open = this.replicaOpenRequest(subscription);
|
|
1411
|
+
if (this.serverCapabilities.replicaBatch === 1) {
|
|
1412
|
+
this.pendingReplicaOpens.add(subscription);
|
|
1413
|
+
if (!this.replicaOpenFlushTimer) {
|
|
1414
|
+
this.replicaOpenFlushTimer = setTimeout(() => this.flushReplicaOpens(), 0);
|
|
1334
1415
|
}
|
|
1335
1416
|
return;
|
|
1336
1417
|
}
|
|
1337
|
-
this.send({ type: "
|
|
1418
|
+
this.send({ type: "replica.open", ...open });
|
|
1338
1419
|
}
|
|
1339
|
-
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
: undefined;
|
|
1420
|
+
replicaOpenRequest(subscription) {
|
|
1421
|
+
const window = this.replica.getWindow(subscription.key);
|
|
1422
|
+
const cursor = window?.cursor;
|
|
1423
|
+
const rows = this.replica.windowRows(subscription.key);
|
|
1424
|
+
const fullIntegrity = cursor !== undefined;
|
|
1425
|
+
const keys = fullIntegrity ? rows.map((row) => replicaRowKey(row, window?.key ?? "id")).filter(Boolean) : undefined;
|
|
1346
1426
|
return {
|
|
1347
1427
|
id: subscription.id,
|
|
1348
1428
|
path: subscription.path,
|
|
1349
1429
|
args: subscription.args,
|
|
1350
|
-
cursor
|
|
1430
|
+
cursor,
|
|
1351
1431
|
keys,
|
|
1352
|
-
hashes:
|
|
1353
|
-
|
|
1354
|
-
: undefined,
|
|
1355
|
-
digest: subscription.cursor ? subscription.integrityDigest : undefined,
|
|
1432
|
+
hashes: undefined,
|
|
1433
|
+
digest: undefined,
|
|
1356
1434
|
fullIntegrity: fullIntegrity || undefined,
|
|
1357
1435
|
};
|
|
1358
1436
|
}
|
|
1359
|
-
|
|
1360
|
-
this.
|
|
1361
|
-
const subscriptions = Array.from(this.
|
|
1362
|
-
this.
|
|
1437
|
+
flushReplicaOpens() {
|
|
1438
|
+
this.replicaOpenFlushTimer = undefined;
|
|
1439
|
+
const subscriptions = Array.from(this.pendingReplicaOpens);
|
|
1440
|
+
this.pendingReplicaOpens.clear();
|
|
1363
1441
|
const opens = subscriptions
|
|
1364
1442
|
.filter((subscription) => (subscription.opening
|
|
1365
1443
|
&& subscription.listeners.size > 0
|
|
1366
|
-
&& this.
|
|
1367
|
-
.map((subscription) => this.
|
|
1368
|
-
for (let offset = 0; offset < opens.length; offset +=
|
|
1369
|
-
this.send({ type: "
|
|
1444
|
+
&& this.replicaSubscriptions.get(subscription.key) === subscription))
|
|
1445
|
+
.map((subscription) => this.replicaOpenRequest(subscription));
|
|
1446
|
+
for (let offset = 0; offset < opens.length; offset += maxReplicaBatchOpens) {
|
|
1447
|
+
this.send({ type: "replica.openMany", opens: opens.slice(offset, offset + maxReplicaBatchOpens) });
|
|
1370
1448
|
}
|
|
1371
1449
|
}
|
|
1372
|
-
|
|
1373
|
-
const subscription = this.
|
|
1450
|
+
unsubscribeReplicaListener(key, listener) {
|
|
1451
|
+
const subscription = this.replicaSubscriptions.get(key);
|
|
1374
1452
|
if (!subscription)
|
|
1375
1453
|
return;
|
|
1376
1454
|
subscription.listeners.delete(listener);
|
|
1377
1455
|
if (subscription.listeners.size > 0 || subscription.unsubscribeTimer)
|
|
1378
1456
|
return;
|
|
1379
1457
|
subscription.unsubscribeTimer = setTimeout(() => {
|
|
1380
|
-
const latest = this.
|
|
1458
|
+
const latest = this.replicaSubscriptions.get(key);
|
|
1381
1459
|
if (!latest || latest.listeners.size > 0)
|
|
1382
1460
|
return;
|
|
1383
1461
|
latest.unsubscribeTimer = undefined;
|
|
1384
|
-
this.
|
|
1385
|
-
this.
|
|
1386
|
-
this.
|
|
1387
|
-
for (const mutationId of this.overlay.removeSource(key))
|
|
1388
|
-
void this.ackOptimisticMutation(mutationId);
|
|
1462
|
+
this.clearReplicaRetry(latest);
|
|
1463
|
+
this.pendingReplicaOpens.delete(latest);
|
|
1464
|
+
this.replicaSubscriptions.delete(key);
|
|
1389
1465
|
this.handlers.delete(latest.id);
|
|
1390
|
-
this.send({ type: "
|
|
1391
|
-
}, this.
|
|
1392
|
-
}
|
|
1393
|
-
persistSyncSnapshot(subscription, fromWatermark = false) {
|
|
1394
|
-
if (!fromWatermark && subscription.watermarkPersistTimer) {
|
|
1395
|
-
clearTimeout(subscription.watermarkPersistTimer);
|
|
1396
|
-
subscription.watermarkPersistTimer = undefined;
|
|
1397
|
-
}
|
|
1398
|
-
const directive = this.queryCacheDirective;
|
|
1399
|
-
const store = this.syncStore;
|
|
1400
|
-
if (!directive || !store || !subscription.cursor)
|
|
1401
|
-
return;
|
|
1402
|
-
const scope = syncPersistenceScope(directive);
|
|
1403
|
-
// sync.ready arrives for every collection on every reload, almost always
|
|
1404
|
-
// with the rows the store already holds. Persist the advancing cursor but
|
|
1405
|
-
// leave the rows alone unless they actually changed.
|
|
1406
|
-
const rowsUnchanged = subscription.persistedRows === subscription.rows;
|
|
1407
|
-
const value = {
|
|
1408
|
-
rows: subscription.rows,
|
|
1409
|
-
cursor: subscription.cursor,
|
|
1410
|
-
keyField: subscription.keyField,
|
|
1411
|
-
mode: subscription.mode,
|
|
1412
|
-
truncated: subscription.truncated,
|
|
1413
|
-
orderBy: subscription.orderBy,
|
|
1414
|
-
orderDirection: subscription.orderDirection,
|
|
1415
|
-
maxRows: subscription.maxRows,
|
|
1416
|
-
maxBytes: subscription.maxBytes,
|
|
1417
|
-
hashes: { ...subscription.hashes },
|
|
1418
|
-
rowsUnchanged,
|
|
1419
|
-
};
|
|
1420
|
-
subscription.persistedRows = subscription.rows;
|
|
1421
|
-
this.enqueueSyncPersistence(subscription, scope, () => store.replace(scope, subscription.path, subscription.args, value));
|
|
1422
|
-
}
|
|
1423
|
-
persistSyncDelta(subscription, upserts, deleted) {
|
|
1424
|
-
if (subscription.watermarkPersistTimer) {
|
|
1425
|
-
clearTimeout(subscription.watermarkPersistTimer);
|
|
1426
|
-
subscription.watermarkPersistTimer = undefined;
|
|
1427
|
-
}
|
|
1428
|
-
const directive = this.queryCacheDirective;
|
|
1429
|
-
const store = this.syncStore;
|
|
1430
|
-
if (!directive || !store || !subscription.cursor)
|
|
1431
|
-
return;
|
|
1432
|
-
const scope = syncPersistenceScope(directive);
|
|
1433
|
-
const value = {
|
|
1434
|
-
cursor: subscription.cursor,
|
|
1435
|
-
keyField: subscription.keyField,
|
|
1436
|
-
mode: subscription.mode,
|
|
1437
|
-
truncated: subscription.truncated,
|
|
1438
|
-
orderBy: subscription.orderBy,
|
|
1439
|
-
orderDirection: subscription.orderDirection,
|
|
1440
|
-
upserts,
|
|
1441
|
-
deleted,
|
|
1442
|
-
maxRows: subscription.maxRows,
|
|
1443
|
-
maxBytes: subscription.maxBytes,
|
|
1444
|
-
hashes: { ...subscription.hashes },
|
|
1445
|
-
};
|
|
1446
|
-
// The delta brings the stored rows to exactly these in-memory rows, so the
|
|
1447
|
-
// sync.ready that closes this batch must not rewrite the whole collection.
|
|
1448
|
-
subscription.persistedRows = subscription.rows;
|
|
1449
|
-
this.enqueueSyncPersistence(subscription, scope, () => store.applyDelta(scope, subscription.path, subscription.args, value));
|
|
1466
|
+
this.send({ type: "replica.close", id: latest.id });
|
|
1467
|
+
}, this.replicaSubscriptionRetentionMs);
|
|
1450
1468
|
}
|
|
1451
1469
|
activateOutboxScope() {
|
|
1452
|
-
const scope =
|
|
1453
|
-
if (scope === this.outboxScope)
|
|
1454
|
-
return this.outboxReady ??
|
|
1470
|
+
const scope = reducerOutboxScope(this.url, this.auth, this.outboxEphemeralScope);
|
|
1471
|
+
if (scope === this.outboxScope) {
|
|
1472
|
+
return this.outboxReady ?? this.replicaReady;
|
|
1473
|
+
}
|
|
1455
1474
|
const previousScope = this.outboxScope;
|
|
1456
1475
|
const generation = ++this.outboxScopeGeneration;
|
|
1457
1476
|
// Pending state from the previous authenticated identity must disappear
|
|
1458
1477
|
// from every live projection immediately. Its durable rows remain scoped
|
|
1459
1478
|
// in IndexedDB and can be resumed only if that identity returns.
|
|
1460
|
-
for (const
|
|
1461
|
-
this.
|
|
1462
|
-
this.
|
|
1479
|
+
for (const reducerId of this.optimisticReducerIds)
|
|
1480
|
+
this.replica.rejectCommand(reducerId);
|
|
1481
|
+
this.optimisticReducerIds.clear();
|
|
1463
1482
|
this.optimisticOutboxEntryIds.clear();
|
|
1464
1483
|
if (isEphemeralOutboxScope(previousScope)) {
|
|
1465
|
-
void this.
|
|
1484
|
+
void this.reducerOutbox.clear(previousScope);
|
|
1466
1485
|
}
|
|
1467
1486
|
this.outboxScope = scope;
|
|
1468
|
-
const ready = this.
|
|
1487
|
+
const ready = this.hasAuthoritativeReplicaScope
|
|
1488
|
+
? this.restoreOutbox(scope, generation)
|
|
1489
|
+
: Promise.resolve();
|
|
1469
1490
|
this.outboxReady = ready;
|
|
1470
1491
|
return ready;
|
|
1471
1492
|
}
|
|
1493
|
+
async activateReplicaDirective(directive) {
|
|
1494
|
+
if (directive.protocolVersion !== 1
|
|
1495
|
+
|| !directive.visibilityScope.trim()
|
|
1496
|
+
|| !directive.epoch.trim()) {
|
|
1497
|
+
this.quarantineReplicaScope();
|
|
1498
|
+
throw new GonvexClientError("Runtime returned an invalid Local Replica scope", { code: "server", operation: "query" });
|
|
1499
|
+
}
|
|
1500
|
+
const scope = directive.visibilityScope.trim();
|
|
1501
|
+
if (this.hasAuthoritativeReplicaScope && this.replicaScope === scope) {
|
|
1502
|
+
await this.replicaReady;
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
for (const reducerId of this.optimisticReducerIds)
|
|
1506
|
+
this.replica.rejectCommand(reducerId);
|
|
1507
|
+
this.optimisticReducerIds.clear();
|
|
1508
|
+
this.optimisticOutboxEntryIds.clear();
|
|
1509
|
+
this.resetReplicaScopeState();
|
|
1510
|
+
this.replicaScope = scope;
|
|
1511
|
+
this.hasAuthoritativeReplicaScope = true;
|
|
1512
|
+
this.replicaReady = this.replica.activateScope(scope);
|
|
1513
|
+
this.rotateSubscriptionScopes();
|
|
1514
|
+
await this.replicaReady;
|
|
1515
|
+
const generation = this.outboxScopeGeneration;
|
|
1516
|
+
this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
|
|
1517
|
+
await this.outboxReady;
|
|
1518
|
+
}
|
|
1519
|
+
quarantineReplicaScope() {
|
|
1520
|
+
// Keep the durable prior identity scope intact for an authorized future
|
|
1521
|
+
// login, but make every synchronous selector fail closed immediately.
|
|
1522
|
+
// The random suffix prevents a denied scope from ever restoring rows.
|
|
1523
|
+
const scope = ["auth-denied", this.url, this.outboxEphemeralScope, randomID()].join("\u0000");
|
|
1524
|
+
for (const reducerId of this.optimisticReducerIds)
|
|
1525
|
+
this.replica.rejectCommand(reducerId);
|
|
1526
|
+
this.optimisticReducerIds.clear();
|
|
1527
|
+
this.optimisticOutboxEntryIds.clear();
|
|
1528
|
+
this.resetReplicaScopeState();
|
|
1529
|
+
this.replicaScope = scope;
|
|
1530
|
+
this.hasAuthoritativeReplicaScope = false;
|
|
1531
|
+
this.replicaReady = this.replica.activateScope(scope, true);
|
|
1532
|
+
this.rotateSubscriptionScopes();
|
|
1533
|
+
}
|
|
1534
|
+
rejectMissingReplicaDirective() {
|
|
1535
|
+
this.quarantineReplicaScope();
|
|
1536
|
+
this.notifyAuthError("Runtime did not provide an authoritative Local Replica visibility scope");
|
|
1537
|
+
}
|
|
1538
|
+
rejectReplicaDirective(error) {
|
|
1539
|
+
this.quarantineReplicaScope();
|
|
1540
|
+
this.notifyAuthError(error instanceof Error ? error.message : "Runtime returned an invalid Local Replica scope");
|
|
1541
|
+
}
|
|
1472
1542
|
async restoreOutbox(scope, generation) {
|
|
1473
|
-
|
|
1543
|
+
await this.replicaReady;
|
|
1544
|
+
const entries = await this.reducerOutbox.loadAll(scope);
|
|
1474
1545
|
if (this.manuallyClosed
|
|
1475
1546
|
|| generation !== this.outboxScopeGeneration
|
|
1476
1547
|
|| scope !== this.outboxScope)
|
|
1477
1548
|
return;
|
|
1478
1549
|
for (const entry of entries) {
|
|
1479
1550
|
if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
|
|
1480
|
-
await this.
|
|
1551
|
+
await this.reducerOutbox.ack(entry.id);
|
|
1481
1552
|
continue;
|
|
1482
1553
|
}
|
|
1483
1554
|
this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
|
|
1484
|
-
this.
|
|
1555
|
+
this.addOptimisticReducer(entry.idempotencyKey, entry.patches ?? [], entry.state === "committed");
|
|
1485
1556
|
}
|
|
1486
1557
|
const nextAttemptAt = Math.min(...entries
|
|
1487
1558
|
.filter((entry) => entry.state === "pending")
|
|
@@ -1494,26 +1565,26 @@ export class GonvexClient {
|
|
|
1494
1565
|
// yields until this restore promise resolves, then safely resumes it.
|
|
1495
1566
|
void this.drainOutbox();
|
|
1496
1567
|
}
|
|
1497
|
-
|
|
1498
|
-
if (patches.length === 0 || this.
|
|
1568
|
+
addOptimisticReducer(reducerId, patches, accepted = false) {
|
|
1569
|
+
if (patches.length === 0 || this.optimisticReducerIds.has(reducerId))
|
|
1499
1570
|
return;
|
|
1500
|
-
this.
|
|
1501
|
-
this.
|
|
1571
|
+
this.optimisticReducerIds.add(reducerId);
|
|
1572
|
+
this.replica.applyOptimistic(reducerId, patches);
|
|
1502
1573
|
}
|
|
1503
|
-
async
|
|
1504
|
-
await
|
|
1574
|
+
async settleOptimisticReducer(reducerId) {
|
|
1575
|
+
await this.ackOptimisticReducer(reducerId);
|
|
1505
1576
|
}
|
|
1506
|
-
async
|
|
1507
|
-
this.
|
|
1508
|
-
this.
|
|
1509
|
-
await this.
|
|
1577
|
+
async rejectOptimisticReducer(reducerId, knownEntryId) {
|
|
1578
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1579
|
+
this.replica.rejectCommand(reducerId);
|
|
1580
|
+
await this.ackOptimisticReducer(reducerId, knownEntryId);
|
|
1510
1581
|
}
|
|
1511
|
-
async
|
|
1512
|
-
const entryId = knownEntryId ?? this.optimisticOutboxEntryIds.get(
|
|
1513
|
-
this.optimisticOutboxEntryIds.delete(
|
|
1514
|
-
this.
|
|
1582
|
+
async ackOptimisticReducer(reducerId, knownEntryId) {
|
|
1583
|
+
const entryId = knownEntryId ?? this.optimisticOutboxEntryIds.get(reducerId);
|
|
1584
|
+
this.optimisticOutboxEntryIds.delete(reducerId);
|
|
1585
|
+
this.optimisticReducerIds.delete(reducerId);
|
|
1515
1586
|
if (entryId !== undefined)
|
|
1516
|
-
await this.
|
|
1587
|
+
await this.reducerOutbox.ack(entryId);
|
|
1517
1588
|
}
|
|
1518
1589
|
async drainOutbox() {
|
|
1519
1590
|
await this.outboxReady;
|
|
@@ -1527,30 +1598,30 @@ export class GonvexClient {
|
|
|
1527
1598
|
try {
|
|
1528
1599
|
while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
|
|
1529
1600
|
const scope = this.outboxScope;
|
|
1530
|
-
const entry = await this.
|
|
1601
|
+
const entry = await this.reducerOutbox.nextReady(scope, Date.now());
|
|
1531
1602
|
if (!entry)
|
|
1532
1603
|
return;
|
|
1533
1604
|
if (scope !== this.outboxScope)
|
|
1534
1605
|
return;
|
|
1535
|
-
await this.
|
|
1606
|
+
await this.reducerOutbox.markInflight(entry.id);
|
|
1536
1607
|
if (scope !== this.outboxScope)
|
|
1537
1608
|
return;
|
|
1538
1609
|
try {
|
|
1539
|
-
await this.call("
|
|
1540
|
-
await this.
|
|
1610
|
+
await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
|
|
1611
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
1541
1612
|
if ((entry.patches?.length ?? 0) > 0) {
|
|
1542
|
-
await this.
|
|
1613
|
+
await this.settleOptimisticReducer(entry.idempotencyKey);
|
|
1543
1614
|
}
|
|
1544
1615
|
else {
|
|
1545
|
-
await this.
|
|
1616
|
+
await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
1546
1617
|
}
|
|
1547
1618
|
}
|
|
1548
1619
|
catch (error) {
|
|
1549
1620
|
if (error instanceof GonvexClientError && error.code === "server") {
|
|
1550
|
-
await this.
|
|
1621
|
+
await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
|
|
1551
1622
|
continue;
|
|
1552
1623
|
}
|
|
1553
|
-
await this.
|
|
1624
|
+
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
1554
1625
|
this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
|
|
1555
1626
|
return;
|
|
1556
1627
|
}
|
|
@@ -1573,63 +1644,70 @@ export class GonvexClient {
|
|
|
1573
1644
|
void this.drainOutbox();
|
|
1574
1645
|
}, delay);
|
|
1575
1646
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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);
|
|
1647
|
+
reducer(ref, args = {}, options = {}) {
|
|
1648
|
+
if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
|
|
1649
|
+
return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
|
|
1582
1650
|
}
|
|
1583
|
-
|
|
1651
|
+
const effectiveOptions = {
|
|
1652
|
+
...options,
|
|
1653
|
+
offline: options.offline ?? (ref.offline?.mode === "allowed" ? "queue" : "reject"),
|
|
1654
|
+
};
|
|
1655
|
+
const reducerId = randomID();
|
|
1656
|
+
const patches = effectiveOptions.optimistic
|
|
1657
|
+
?? optimisticPatchesFromReference(ref.optimistic?.transaction, args);
|
|
1658
|
+
if (patches.length === 0 && effectiveOptions.offline !== "queue") {
|
|
1659
|
+
return this.call("reducer", ref, args, effectiveOptions.timeoutMs ?? this.timeouts.reducerTimeoutMs, reducerId);
|
|
1660
|
+
}
|
|
1661
|
+
return this.runOptimisticReducer(ref, args, effectiveOptions, reducerId, patches);
|
|
1584
1662
|
}
|
|
1585
|
-
async
|
|
1663
|
+
async runOptimisticReducer(ref, args, options, reducerId, patches) {
|
|
1586
1664
|
// The startup recovery transaction converts abandoned inflight entries to
|
|
1587
1665
|
// pending. Finish it before inserting a brand-new direct send, otherwise
|
|
1588
|
-
// recovery can mistake that live entry for a crashed
|
|
1666
|
+
// recovery can mistake that live entry for a crashed reducer and race the
|
|
1589
1667
|
// direct call through the background drain.
|
|
1590
1668
|
await this.outboxReady;
|
|
1591
1669
|
if (this.manuallyClosed) {
|
|
1592
|
-
throw new GonvexClientError(`Gonvex client was closed before
|
|
1670
|
+
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
1593
1671
|
}
|
|
1594
1672
|
const scope = this.outboxScope;
|
|
1595
|
-
const entry = await this.
|
|
1673
|
+
const entry = await this.reducerOutbox.enqueue({
|
|
1596
1674
|
scope,
|
|
1597
1675
|
path: ref.path,
|
|
1598
1676
|
args,
|
|
1599
|
-
idempotencyKey:
|
|
1677
|
+
idempotencyKey: reducerId,
|
|
1600
1678
|
entityKeys: patches.map((patch) => `${patch.entity ?? patch.collection ?? ""}:${patch.rowId}`),
|
|
1601
1679
|
patches,
|
|
1602
1680
|
state: "inflight",
|
|
1603
1681
|
});
|
|
1604
1682
|
if (this.manuallyClosed) {
|
|
1605
|
-
await this.
|
|
1606
|
-
throw new GonvexClientError(`Gonvex client was closed before
|
|
1683
|
+
await this.reducerOutbox.ack(entry.id);
|
|
1684
|
+
throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
|
|
1607
1685
|
}
|
|
1608
1686
|
if (scope !== this.outboxScope) {
|
|
1609
|
-
await this.
|
|
1610
|
-
throw new GonvexClientError(`Authentication changed before
|
|
1687
|
+
await this.reducerOutbox.ack(entry.id);
|
|
1688
|
+
throw new GonvexClientError(`Authentication changed before reducer ${ref.path} could be sent.`, { code: "disconnected", path: ref.path, operation: "reducer" });
|
|
1611
1689
|
}
|
|
1612
|
-
this.optimisticOutboxEntryIds.set(
|
|
1613
|
-
this.
|
|
1690
|
+
this.optimisticOutboxEntryIds.set(reducerId, entry.id);
|
|
1691
|
+
this.addOptimisticReducer(reducerId, patches);
|
|
1614
1692
|
try {
|
|
1615
1693
|
// The direct send is outbox-managed: a crash here replays the entry
|
|
1616
1694
|
// with the same idempotency key, so the server must dedupe it.
|
|
1617
|
-
const result = await this.call("
|
|
1618
|
-
await this.
|
|
1695
|
+
const result = await this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs, reducerId, reducerId);
|
|
1696
|
+
await this.reducerOutbox.markCommitted(entry.id);
|
|
1619
1697
|
if (patches.length > 0) {
|
|
1620
|
-
await this.
|
|
1698
|
+
await this.settleOptimisticReducer(reducerId);
|
|
1621
1699
|
}
|
|
1622
1700
|
else {
|
|
1623
|
-
await this.
|
|
1701
|
+
await this.ackOptimisticReducer(reducerId, entry.id);
|
|
1624
1702
|
}
|
|
1625
1703
|
return result;
|
|
1626
1704
|
}
|
|
1627
1705
|
catch (error) {
|
|
1628
|
-
if (
|
|
1629
|
-
await this.
|
|
1630
|
-
return { status: "queued",
|
|
1706
|
+
if (isQueueableReducerError(error) && options.offline === "queue") {
|
|
1707
|
+
await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
|
|
1708
|
+
return { status: "queued", reducerId };
|
|
1631
1709
|
}
|
|
1632
|
-
await this.
|
|
1710
|
+
await this.rejectOptimisticReducer(reducerId, entry.id);
|
|
1633
1711
|
throw error;
|
|
1634
1712
|
}
|
|
1635
1713
|
}
|
|
@@ -1641,7 +1719,10 @@ export class GonvexClient {
|
|
|
1641
1719
|
const id = randomID();
|
|
1642
1720
|
const timeoutMs = options.timeoutMs ?? this.timeouts.queryTimeoutMs;
|
|
1643
1721
|
return new Promise((resolve, reject) => {
|
|
1644
|
-
const query = {
|
|
1722
|
+
const query = {
|
|
1723
|
+
id, path: ref.path, args, scope: ref.scope ?? "tenant",
|
|
1724
|
+
authorization: ref.authorization, reject,
|
|
1725
|
+
};
|
|
1645
1726
|
const settle = () => {
|
|
1646
1727
|
if (query.timeoutTimer)
|
|
1647
1728
|
clearTimeout(query.timeoutTimer);
|
|
@@ -1652,7 +1733,6 @@ export class GonvexClient {
|
|
|
1652
1733
|
if (timeoutMs > 0) {
|
|
1653
1734
|
query.timeoutTimer = setTimeout(() => {
|
|
1654
1735
|
settle();
|
|
1655
|
-
this.send({ type: "query.unsubscribe", id });
|
|
1656
1736
|
reject(new GonvexClientError(`Query ${ref.path} timed out after ${timeoutMs}ms`, { code: "timeout", path: ref.path, operation: "query" }));
|
|
1657
1737
|
}, timeoutMs);
|
|
1658
1738
|
}
|
|
@@ -1669,7 +1749,6 @@ export class GonvexClient {
|
|
|
1669
1749
|
clientReceivedAtMs: nowMs(),
|
|
1670
1750
|
serverTrace: message.trace,
|
|
1671
1751
|
});
|
|
1672
|
-
this.send({ type: "query.unsubscribe", id });
|
|
1673
1752
|
resolve(message.result);
|
|
1674
1753
|
}
|
|
1675
1754
|
if (message.type === "query.error") {
|
|
@@ -1682,7 +1761,6 @@ export class GonvexClient {
|
|
|
1682
1761
|
error: message.error,
|
|
1683
1762
|
clientReceivedAtMs: nowMs(),
|
|
1684
1763
|
});
|
|
1685
|
-
this.send({ type: "query.unsubscribe", id });
|
|
1686
1764
|
reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: "query" }));
|
|
1687
1765
|
}
|
|
1688
1766
|
});
|
|
@@ -1695,7 +1773,7 @@ export class GonvexClient {
|
|
|
1695
1773
|
* e.g. after a `query.error` or when a subscriber gave up waiting. No-op if
|
|
1696
1774
|
* nothing is subscribed to this query.
|
|
1697
1775
|
*/
|
|
1698
|
-
|
|
1776
|
+
retryLiveQuery(ref, args = {}) {
|
|
1699
1777
|
const subscription = this.querySubscriptions.get(querySubscriptionKey(ref, args));
|
|
1700
1778
|
if (!subscription || subscription.listeners.size === 0)
|
|
1701
1779
|
return;
|
|
@@ -1705,44 +1783,44 @@ export class GonvexClient {
|
|
|
1705
1783
|
this.sendSubscription(subscription);
|
|
1706
1784
|
}
|
|
1707
1785
|
/**
|
|
1708
|
-
* Flush a queue of
|
|
1786
|
+
* Flush a queue of reducers in one `reducer.callMany` frame (queue order,
|
|
1709
1787
|
* one websocket round trip). Each entry settles independently — a failed
|
|
1710
1788
|
* call does not reject the batch — so offline queues can apply per-row
|
|
1711
|
-
* outcomes. Falls back to the standard per-
|
|
1789
|
+
* outcomes. Falls back to the standard per-reducer path when the runtime
|
|
1712
1790
|
* lacks batching or when a call needs generated/explicit optimism or durable
|
|
1713
|
-
* offline queuing, so there is never a second
|
|
1791
|
+
* offline queuing, so there is never a second reducer-state implementation.
|
|
1714
1792
|
*/
|
|
1715
|
-
async
|
|
1793
|
+
async reducerMany(calls, options = {}) {
|
|
1716
1794
|
if (calls.length === 0)
|
|
1717
1795
|
return [];
|
|
1718
1796
|
this.connect();
|
|
1719
|
-
const timeoutMs = options.timeoutMs ?? this.timeouts.
|
|
1797
|
+
const timeoutMs = options.timeoutMs ?? this.timeouts.reducerTimeoutMs;
|
|
1720
1798
|
const settle = (promise, path) => promise
|
|
1721
1799
|
.then((result) => ({ status: "ok", result }))
|
|
1722
1800
|
.catch((error) => ({
|
|
1723
1801
|
status: "error",
|
|
1724
1802
|
error: error instanceof GonvexClientError
|
|
1725
1803
|
? error
|
|
1726
|
-
: new GonvexClientError(String(error), { code: "server", path, operation: "
|
|
1804
|
+
: new GonvexClientError(String(error), { code: "server", path, operation: "reducer" }),
|
|
1727
1805
|
}));
|
|
1728
|
-
const
|
|
1806
|
+
const requiresStandardReducerPath = options.offline === "queue"
|
|
1729
1807
|
|| options.optimistic !== undefined
|
|
1730
|
-
|| calls.some((call) => call.ref.optimistic?.
|
|
1731
|
-
if (this.serverCapabilities.
|
|
1808
|
+
|| calls.some((call) => call.ref.optimistic?.transaction !== undefined || call.ref.scope === "control");
|
|
1809
|
+
if (this.serverCapabilities.reducerBatch !== 1 || requiresStandardReducerPath) {
|
|
1732
1810
|
const outcomes = [];
|
|
1733
1811
|
for (const call of calls) {
|
|
1734
|
-
outcomes.push(await settle(this.
|
|
1812
|
+
outcomes.push(await settle(this.reducer(call.ref, call.args ?? {}, options), call.ref.path));
|
|
1735
1813
|
}
|
|
1736
1814
|
return outcomes;
|
|
1737
1815
|
}
|
|
1738
1816
|
const registered = calls.map((call) => {
|
|
1739
|
-
const entry = this.registerCall("
|
|
1817
|
+
const entry = this.registerCall("reducer", call.ref, call.args ?? {}, timeoutMs);
|
|
1740
1818
|
return { ...entry, path: call.ref.path, args: call.args ?? {} };
|
|
1741
1819
|
});
|
|
1742
|
-
for (let offset = 0; offset < registered.length; offset +=
|
|
1820
|
+
for (let offset = 0; offset < registered.length; offset += maxReplicaBatchOpens) {
|
|
1743
1821
|
this.send({
|
|
1744
|
-
type: "
|
|
1745
|
-
calls: registered.slice(offset, offset +
|
|
1822
|
+
type: "reducer.callMany",
|
|
1823
|
+
calls: registered.slice(offset, offset + maxReplicaBatchOpens).map((entry) => ({
|
|
1746
1824
|
id: entry.id,
|
|
1747
1825
|
path: entry.path,
|
|
1748
1826
|
args: entry.args,
|
|
@@ -1755,34 +1833,48 @@ export class GonvexClient {
|
|
|
1755
1833
|
}
|
|
1756
1834
|
call(kind, ref, args, timeoutMs, id, idempotencyKey) {
|
|
1757
1835
|
this.connect();
|
|
1758
|
-
const
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
this.send({
|
|
1767
|
-
type: "mutation.call",
|
|
1836
|
+
const callId = id ?? randomID();
|
|
1837
|
+
const effectiveIdempotencyKey = ref.scope === "control"
|
|
1838
|
+
? (idempotencyKey ?? callId)
|
|
1839
|
+
: idempotencyKey;
|
|
1840
|
+
const entry = this.registerCall(kind, ref, args, timeoutMs, callId, effectiveIdempotencyKey);
|
|
1841
|
+
if (kind === "reducer") {
|
|
1842
|
+
this.sendInvocation(ref, {
|
|
1843
|
+
type: "reducer.call",
|
|
1768
1844
|
id: entry.id,
|
|
1769
1845
|
path: ref.path,
|
|
1770
1846
|
args,
|
|
1847
|
+
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
1771
1848
|
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
1772
|
-
...(
|
|
1849
|
+
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
1773
1850
|
});
|
|
1774
1851
|
}
|
|
1775
1852
|
else {
|
|
1776
|
-
this.
|
|
1853
|
+
this.sendInvocation(ref, {
|
|
1854
|
+
type: "action.call", id: entry.id, path: ref.path, args,
|
|
1855
|
+
...(ref.scope === "control" ? { scope: "control" } : {}),
|
|
1856
|
+
...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
|
|
1857
|
+
trace: { clientSentAtMs: entry.clientSentAtMs },
|
|
1858
|
+
});
|
|
1777
1859
|
}
|
|
1778
1860
|
this.notifyConnectionState();
|
|
1779
1861
|
return entry.promise;
|
|
1780
1862
|
}
|
|
1781
|
-
registerCall(kind, ref, args, timeoutMs, callId = randomID()) {
|
|
1863
|
+
registerCall(kind, ref, args, timeoutMs, callId = randomID(), idempotencyKey) {
|
|
1782
1864
|
const id = callId;
|
|
1783
1865
|
const clientSentAtMs = nowMs();
|
|
1784
1866
|
const promise = new Promise((resolve, reject) => {
|
|
1785
|
-
const pending = {
|
|
1867
|
+
const pending = {
|
|
1868
|
+
id,
|
|
1869
|
+
kind,
|
|
1870
|
+
path: ref.path,
|
|
1871
|
+
args,
|
|
1872
|
+
scope: ref.scope ?? "tenant",
|
|
1873
|
+
authorization: ref.authorization,
|
|
1874
|
+
idempotencyKey,
|
|
1875
|
+
socketGeneration: this.socketGeneration,
|
|
1876
|
+
reject,
|
|
1877
|
+
};
|
|
1786
1878
|
const settle = () => {
|
|
1787
1879
|
if (pending.timeoutTimer)
|
|
1788
1880
|
clearTimeout(pending.timeoutTimer);
|
|
@@ -1793,17 +1885,18 @@ export class GonvexClient {
|
|
|
1793
1885
|
if (timeoutMs > 0) {
|
|
1794
1886
|
pending.timeoutTimer = setTimeout(() => {
|
|
1795
1887
|
settle();
|
|
1796
|
-
reject(new GonvexClientError(`${kind === "
|
|
1888
|
+
reject(new GonvexClientError(`${kind === "reducer" ? "Reducer" : "Action"} ${ref.path} timed out after ${timeoutMs}ms. The operation may or may not have been applied.`, { code: "timeout", path: ref.path, operation: kind }));
|
|
1797
1889
|
}, timeoutMs);
|
|
1798
1890
|
}
|
|
1799
1891
|
this.pendingCalls.set(id, pending);
|
|
1800
1892
|
this.handlers.set(id, (message) => {
|
|
1801
|
-
if (kind === "
|
|
1893
|
+
if (kind === "reducer" && message.type === "reducer.result") {
|
|
1802
1894
|
settle();
|
|
1895
|
+
this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
|
|
1803
1896
|
this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
|
|
1804
1897
|
resolve(message.result);
|
|
1805
1898
|
}
|
|
1806
|
-
if (kind === "
|
|
1899
|
+
if (kind === "reducer" && message.type === "reducer.error") {
|
|
1807
1900
|
settle();
|
|
1808
1901
|
this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
|
|
1809
1902
|
reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
|
|
@@ -1838,8 +1931,6 @@ export class GonvexClient {
|
|
|
1838
1931
|
if (!latest || latest.listeners.size > 0)
|
|
1839
1932
|
return;
|
|
1840
1933
|
this.querySubscriptions.delete(key);
|
|
1841
|
-
for (const mutationId of this.overlay.removeSource(key))
|
|
1842
|
-
void this.ackOptimisticMutation(mutationId);
|
|
1843
1934
|
this.send({ type: "query.unsubscribe", id: latest.id });
|
|
1844
1935
|
setTimeout(() => this.handlers.delete(latest.id), 500);
|
|
1845
1936
|
}, this.querySubscriptionRetentionMs);
|
|
@@ -1849,19 +1940,7 @@ export class GonvexClient {
|
|
|
1849
1940
|
return;
|
|
1850
1941
|
if (subscription.socketGeneration === this.socketGeneration)
|
|
1851
1942
|
return;
|
|
1852
|
-
|
|
1853
|
-
if (!this.queryCacheDirective) {
|
|
1854
|
-
if (this.queryCacheNegotiatedSocketGeneration !== this.socketGeneration)
|
|
1855
|
-
return;
|
|
1856
|
-
}
|
|
1857
|
-
else if (subscription.cacheReadGeneration !== this.queryCacheGeneration) {
|
|
1858
|
-
this.startQueryCacheRead(subscription);
|
|
1859
|
-
return;
|
|
1860
|
-
}
|
|
1861
|
-
else if (subscription.cacheReadPromise) {
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1943
|
+
subscription.scope = this.replicaScope;
|
|
1865
1944
|
subscription.socketGeneration = this.socketGeneration;
|
|
1866
1945
|
// Route reloads register dozens of live queries at once. Collapse the
|
|
1867
1946
|
// burst into one batched frame per tick instead of one frame per query.
|
|
@@ -1877,7 +1956,8 @@ export class GonvexClient {
|
|
|
1877
1956
|
id: subscription.id,
|
|
1878
1957
|
path: subscription.path,
|
|
1879
1958
|
args: subscription.args,
|
|
1880
|
-
|
|
1959
|
+
...(subscription.executionScope === "control" ? { scope: "control" } : {}),
|
|
1960
|
+
windowRevision: undefined,
|
|
1881
1961
|
});
|
|
1882
1962
|
}
|
|
1883
1963
|
flushQuerySubscribes() {
|
|
@@ -1892,10 +1972,11 @@ export class GonvexClient {
|
|
|
1892
1972
|
id: subscription.id,
|
|
1893
1973
|
path: subscription.path,
|
|
1894
1974
|
args: subscription.args,
|
|
1895
|
-
|
|
1975
|
+
...(subscription.executionScope === "control" ? { scope: "control" } : {}),
|
|
1976
|
+
windowRevision: undefined,
|
|
1896
1977
|
}));
|
|
1897
|
-
for (let offset = 0; offset < subscribes.length; offset +=
|
|
1898
|
-
this.send({ type: "query.subscribeMany", subscribes: subscribes.slice(offset, offset +
|
|
1978
|
+
for (let offset = 0; offset < subscribes.length; offset += maxReplicaBatchOpens) {
|
|
1979
|
+
this.send({ type: "query.subscribeMany", subscribes: subscribes.slice(offset, offset + maxReplicaBatchOpens) });
|
|
1899
1980
|
}
|
|
1900
1981
|
}
|
|
1901
1982
|
resumeQuerySubscriptions() {
|
|
@@ -1905,25 +1986,20 @@ export class GonvexClient {
|
|
|
1905
1986
|
this.sendSubscription(subscription);
|
|
1906
1987
|
}
|
|
1907
1988
|
}
|
|
1908
|
-
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
.
|
|
1913
|
-
.
|
|
1914
|
-
.
|
|
1915
|
-
|
|
1916
|
-
subscription.persistence = pending;
|
|
1917
|
-
void pending.finally(() => {
|
|
1918
|
-
if (this.syncPersistence.get(key) === pending)
|
|
1919
|
-
this.syncPersistence.delete(key);
|
|
1920
|
-
});
|
|
1989
|
+
resumeReplicaSubscriptions() {
|
|
1990
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1991
|
+
if (subscription.listeners.size === 0)
|
|
1992
|
+
continue;
|
|
1993
|
+
subscription.opening = false;
|
|
1994
|
+
subscription.socketGeneration = undefined;
|
|
1995
|
+
this.sendReplicaOpen(subscription);
|
|
1996
|
+
}
|
|
1921
1997
|
}
|
|
1922
|
-
|
|
1998
|
+
scheduleReplicaRetry(subscription) {
|
|
1923
1999
|
if (this.manuallyClosed
|
|
1924
2000
|
|| subscription.retryTimer
|
|
1925
2001
|
|| subscription.listeners.size === 0
|
|
1926
|
-
|| this.
|
|
2002
|
+
|| this.replicaSubscriptions.get(subscription.key) !== subscription)
|
|
1927
2003
|
return;
|
|
1928
2004
|
const delay = Math.min(250 * (2 ** subscription.retryAttempt), 5_000);
|
|
1929
2005
|
subscription.retryAttempt += 1;
|
|
@@ -1932,13 +2008,13 @@ export class GonvexClient {
|
|
|
1932
2008
|
if (this.manuallyClosed
|
|
1933
2009
|
|| !this.isWebSocketConnected
|
|
1934
2010
|
|| subscription.listeners.size === 0
|
|
1935
|
-
|| this.
|
|
2011
|
+
|| this.replicaSubscriptions.get(subscription.key) !== subscription)
|
|
1936
2012
|
return;
|
|
1937
2013
|
subscription.opening = false;
|
|
1938
|
-
this.
|
|
2014
|
+
this.sendReplicaOpen(subscription);
|
|
1939
2015
|
}, delay);
|
|
1940
2016
|
}
|
|
1941
|
-
|
|
2017
|
+
clearReplicaRetry(subscription, resetAttempt = false) {
|
|
1942
2018
|
if (subscription.retryTimer) {
|
|
1943
2019
|
clearTimeout(subscription.retryTimer);
|
|
1944
2020
|
subscription.retryTimer = undefined;
|
|
@@ -1949,7 +2025,6 @@ export class GonvexClient {
|
|
|
1949
2025
|
requestSubscriptionSnapshot(subscription) {
|
|
1950
2026
|
// Do not advertise the cache revision while recovering. Otherwise the
|
|
1951
2027
|
// runtime can answer with another progress frame instead of a snapshot.
|
|
1952
|
-
subscription.cachedRevision = undefined;
|
|
1953
2028
|
subscription.serverSettled = false;
|
|
1954
2029
|
subscription.socketGeneration = undefined;
|
|
1955
2030
|
this.sendSubscription(subscription);
|
|
@@ -1958,7 +2033,11 @@ export class GonvexClient {
|
|
|
1958
2033
|
if (query.socketGeneration === this.socketGeneration)
|
|
1959
2034
|
return;
|
|
1960
2035
|
query.socketGeneration = this.socketGeneration;
|
|
1961
|
-
|
|
2036
|
+
const message = { type: "query.call", id: query.id, path: query.path, args: query.args, ...(query.scope === "control" ? { scope: "control" } : {}) };
|
|
2037
|
+
if (query.scope === "control" && query.authorization === "public" && this.authInFlight)
|
|
2038
|
+
this.sendNow(message);
|
|
2039
|
+
else
|
|
2040
|
+
this.send(message);
|
|
1962
2041
|
}
|
|
1963
2042
|
resubscribeQueries(generation) {
|
|
1964
2043
|
if (generation !== this.socketGeneration)
|
|
@@ -1972,15 +2051,65 @@ export class GonvexClient {
|
|
|
1972
2051
|
for (const query of this.oneShotQueries.values()) {
|
|
1973
2052
|
this.sendOneShotQuery(query);
|
|
1974
2053
|
}
|
|
1975
|
-
for (const
|
|
2054
|
+
for (const call of this.pendingCalls.values()) {
|
|
2055
|
+
if (call.scope === "control")
|
|
2056
|
+
this.sendPendingControlCall(call);
|
|
2057
|
+
}
|
|
2058
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
1976
2059
|
if (subscription.listeners.size === 0)
|
|
1977
2060
|
continue;
|
|
1978
|
-
this.
|
|
2061
|
+
this.clearReplicaRetry(subscription, true);
|
|
1979
2062
|
subscription.opening = false;
|
|
1980
2063
|
subscription.socketGeneration = undefined;
|
|
1981
|
-
this.
|
|
2064
|
+
this.sendReplicaOpen(subscription);
|
|
1982
2065
|
}
|
|
1983
2066
|
}
|
|
2067
|
+
sendPendingControlCall(call) {
|
|
2068
|
+
if (call.socketGeneration === this.socketGeneration)
|
|
2069
|
+
return;
|
|
2070
|
+
call.socketGeneration = this.socketGeneration;
|
|
2071
|
+
const trace = { clientSentAtMs: nowMs() };
|
|
2072
|
+
if (call.kind === "reducer") {
|
|
2073
|
+
const message = {
|
|
2074
|
+
type: "reducer.call",
|
|
2075
|
+
id: call.id,
|
|
2076
|
+
path: call.path,
|
|
2077
|
+
args: call.args,
|
|
2078
|
+
scope: "control",
|
|
2079
|
+
idempotencyKey: call.idempotencyKey ?? call.id,
|
|
2080
|
+
trace,
|
|
2081
|
+
};
|
|
2082
|
+
if (call.authorization === "public" && this.authInFlight)
|
|
2083
|
+
this.sendNow(message);
|
|
2084
|
+
else
|
|
2085
|
+
this.send(message);
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const message = {
|
|
2089
|
+
type: "action.call", id: call.id, path: call.path, args: call.args,
|
|
2090
|
+
scope: "control", idempotencyKey: call.idempotencyKey ?? call.id, trace,
|
|
2091
|
+
};
|
|
2092
|
+
if (call.authorization === "public" && this.authInFlight)
|
|
2093
|
+
this.sendNow(message);
|
|
2094
|
+
else
|
|
2095
|
+
this.send(message);
|
|
2096
|
+
}
|
|
2097
|
+
sendInvocation(ref, message) {
|
|
2098
|
+
if (ref.scope === "control" && ref.authorization === "public" && this.authInFlight) {
|
|
2099
|
+
this.sendNow(message);
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
this.send(message);
|
|
2103
|
+
}
|
|
2104
|
+
hasControlPlaneWork() {
|
|
2105
|
+
for (const query of this.oneShotQueries.values())
|
|
2106
|
+
if (query.scope === "control")
|
|
2107
|
+
return true;
|
|
2108
|
+
for (const call of this.pendingCalls.values())
|
|
2109
|
+
if (call.scope === "control")
|
|
2110
|
+
return true;
|
|
2111
|
+
return false;
|
|
2112
|
+
}
|
|
1984
2113
|
scheduleReconnect() {
|
|
1985
2114
|
if (this.manuallyClosed || this.reconnectTimer)
|
|
1986
2115
|
return;
|
|
@@ -1994,204 +2123,53 @@ export class GonvexClient {
|
|
|
1994
2123
|
}
|
|
1995
2124
|
}, delay);
|
|
1996
2125
|
}
|
|
1997
|
-
|
|
1998
|
-
if (!validQueryCacheDirective(value)) {
|
|
1999
|
-
if (this.queryCacheDirective)
|
|
2000
|
-
this.resetQueryCacheScope();
|
|
2001
|
-
return;
|
|
2002
|
-
}
|
|
2003
|
-
const previous = this.queryCacheDirective;
|
|
2004
|
-
const syncScopeChanged = previous !== undefined
|
|
2005
|
-
&& syncPersistenceScope(previous) !== syncPersistenceScope(value);
|
|
2006
|
-
if (previous?.scope === value.scope && !syncScopeChanged) {
|
|
2007
|
-
this.queryCacheDirective = value;
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
if (previous) {
|
|
2011
|
-
// A deploy rotates the query-result scope (results depend on code), but
|
|
2012
|
-
// sync collections are keyed by visibility and survive it: their rows,
|
|
2013
|
-
// cursors, and in-flight warm reads stay valid and are verified by the
|
|
2014
|
-
// server's reconcile on the next open.
|
|
2015
|
-
this.resetQueryResultCacheState();
|
|
2016
|
-
if (syncScopeChanged)
|
|
2017
|
-
this.resetSyncCacheState();
|
|
2018
|
-
}
|
|
2019
|
-
this.queryCacheDirective = value;
|
|
2020
|
-
const identity = authIdentityKey(this.auth);
|
|
2021
|
-
if (identity)
|
|
2022
|
-
void this.syncStore?.saveDirective(identity, value).catch(() => undefined);
|
|
2023
|
-
for (const subscription of this.querySubscriptions.values()) {
|
|
2024
|
-
this.startQueryCacheRead(subscription);
|
|
2025
|
-
}
|
|
2026
|
-
for (const subscription of this.syncSubscriptions.values()) {
|
|
2027
|
-
this.startSync(subscription);
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
recoverWarmSyncDirective() {
|
|
2031
|
-
const store = this.syncStore;
|
|
2032
|
-
const identity = authIdentityKey(this.auth);
|
|
2033
|
-
const generation = ++this.syncIdentityGeneration;
|
|
2034
|
-
if (!store || !identity)
|
|
2035
|
-
return;
|
|
2036
|
-
// Same deadline as the warm collection reads: a hung IndexedDB must not
|
|
2037
|
-
// stall directive recovery — the server's auth.result supplies it anyway.
|
|
2038
|
-
const abandonTimer = setTimeout(() => {
|
|
2039
|
-
// Only invalidate this recovery — a newer setAuth may already own the
|
|
2040
|
-
// current generation.
|
|
2041
|
-
if (generation === this.syncIdentityGeneration)
|
|
2042
|
-
this.syncIdentityGeneration += 1;
|
|
2043
|
-
}, syncStoreReadTimeoutMs);
|
|
2044
|
-
void store.loadDirective(identity).then((directive) => {
|
|
2045
|
-
clearTimeout(abandonTimer);
|
|
2046
|
-
if (generation !== this.syncIdentityGeneration
|
|
2047
|
-
|| authIdentityKey(this.auth) !== identity
|
|
2048
|
-
|| this.queryCacheDirective
|
|
2049
|
-
|| !validQueryCacheDirective(directive))
|
|
2050
|
-
return;
|
|
2051
|
-
this.installQueryCacheDirective(directive);
|
|
2052
|
-
}).catch(() => {
|
|
2053
|
-
clearTimeout(abandonTimer);
|
|
2054
|
-
});
|
|
2055
|
-
}
|
|
2056
|
-
resetQueryCacheScope() {
|
|
2057
|
-
const hadScope = this.queryCacheDirective !== undefined;
|
|
2058
|
-
this.queryCacheDirective = undefined;
|
|
2059
|
-
this.resetQueryResultCacheState();
|
|
2060
|
-
this.resetSyncCacheState();
|
|
2061
|
-
if (hadScope || this.querySubscriptions.size > 0 || this.syncSubscriptions.size > 0) {
|
|
2062
|
-
for (const handler of this.sessionScopeHandlers)
|
|
2063
|
-
handler();
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
resetQueryResultCacheState() {
|
|
2067
|
-
this.queryCacheGeneration += 1;
|
|
2068
|
-
this.queryCacheNegotiatedSocketGeneration = undefined;
|
|
2126
|
+
resetReplicaScopeState() {
|
|
2069
2127
|
for (const subscription of this.querySubscriptions.values()) {
|
|
2070
2128
|
subscription.lastMessage = undefined;
|
|
2071
2129
|
subscription.serverSettled = false;
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
2076
|
-
subscription.cacheReadFallbackTimer = undefined;
|
|
2077
|
-
subscription.cachedRevision = undefined;
|
|
2078
|
-
}
|
|
2079
|
-
}
|
|
2080
|
-
resetSyncCacheState() {
|
|
2081
|
-
this.syncScopeGeneration += 1;
|
|
2082
|
-
for (const subscription of this.syncSubscriptions.values()) {
|
|
2083
|
-
this.clearSyncRetry(subscription, true);
|
|
2084
|
-
if (subscription.watermarkPersistTimer) {
|
|
2085
|
-
clearTimeout(subscription.watermarkPersistTimer);
|
|
2086
|
-
subscription.watermarkPersistTimer = undefined;
|
|
2087
|
-
}
|
|
2130
|
+
}
|
|
2131
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
2132
|
+
this.clearReplicaRetry(subscription, true);
|
|
2088
2133
|
subscription.isUpToDate = false;
|
|
2089
|
-
subscription.rows = [];
|
|
2090
|
-
subscription.persistedRows = undefined;
|
|
2091
|
-
subscription.hashes = {};
|
|
2092
|
-
subscription.integrityDigest = undefined;
|
|
2093
|
-
subscription.integrityRows = undefined;
|
|
2094
|
-
subscription.integrityEpoch = undefined;
|
|
2095
|
-
subscription.forceFullIntegrity = false;
|
|
2096
|
-
subscription.cursor = undefined;
|
|
2097
2134
|
subscription.cursorFloor = undefined;
|
|
2098
2135
|
subscription.retiredEpochs.clear();
|
|
2099
2136
|
subscription.lastMessage = undefined;
|
|
2100
|
-
subscription.cacheReadGeneration = undefined;
|
|
2101
2137
|
subscription.opening = false;
|
|
2102
2138
|
subscription.verificationGeneration += 1;
|
|
2103
2139
|
}
|
|
2140
|
+
for (const handler of this.sessionScopeHandlers)
|
|
2141
|
+
handler();
|
|
2104
2142
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
};
|
|
2135
|
-
subscription.lastMessage = message;
|
|
2136
|
-
const outgoing = this.materializeQueryMessage(subscription, message);
|
|
2137
|
-
for (const listener of Array.from(subscription.listeners)) {
|
|
2138
|
-
listener(outgoing);
|
|
2139
|
-
}
|
|
2140
|
-
this.acknowledgeOptimisticQuerySnapshot(subscription, message.result);
|
|
2141
|
-
}).catch(() => {
|
|
2142
|
-
// Persistent cache failures never affect the server query path.
|
|
2143
|
-
}).finally(() => {
|
|
2144
|
-
if (subscription.cacheReadFallbackTimer)
|
|
2145
|
-
clearTimeout(subscription.cacheReadFallbackTimer);
|
|
2146
|
-
subscription.cacheReadFallbackTimer = undefined;
|
|
2147
|
-
if (subscription.cacheReadPromise === read)
|
|
2148
|
-
subscription.cacheReadPromise = undefined;
|
|
2149
|
-
if (this.querySubscriptions.get(subscription.key) === subscription
|
|
2150
|
-
&& subscription.listeners.size > 0
|
|
2151
|
-
&& this.queryCacheGeneration === generation
|
|
2152
|
-
&& this.queryCacheDirective?.scope === directive.scope) {
|
|
2153
|
-
this.sendSubscription(subscription);
|
|
2154
|
-
}
|
|
2155
|
-
});
|
|
2156
|
-
subscription.cacheReadPromise = read;
|
|
2157
|
-
subscription.cacheReadFallbackTimer = setTimeout(() => {
|
|
2158
|
-
if (subscription.cacheReadPromise !== read)
|
|
2159
|
-
return;
|
|
2160
|
-
subscription.cacheReadPromise = undefined;
|
|
2161
|
-
subscription.cacheReadFallbackTimer = undefined;
|
|
2162
|
-
this.sendSubscription(subscription);
|
|
2163
|
-
}, this.queryCacheReadTimeoutMs);
|
|
2164
|
-
}
|
|
2165
|
-
persistQueryResult(subscription, message) {
|
|
2166
|
-
const store = this.queryCache;
|
|
2167
|
-
const directive = this.queryCacheDirective;
|
|
2168
|
-
if (!store
|
|
2169
|
-
|| !directive
|
|
2170
|
-
|| message.cacheScope !== directive.scope
|
|
2171
|
-
|| !message.cacheRevision) {
|
|
2172
|
-
return;
|
|
2143
|
+
/**
|
|
2144
|
+
* A subscription id is also the server's response routing key. Rotate it on
|
|
2145
|
+
* auth scope changes so a delayed frame from the previous tenant cannot be
|
|
2146
|
+
* delivered to a handler that now materializes into the new scope.
|
|
2147
|
+
*/
|
|
2148
|
+
rotateSubscriptionScopes() {
|
|
2149
|
+
for (const subscription of this.querySubscriptions.values()) {
|
|
2150
|
+
this.handlers.delete(subscription.id);
|
|
2151
|
+
this.pendingQuerySubscribes.delete(subscription);
|
|
2152
|
+
subscription.id = randomID();
|
|
2153
|
+
subscription.socketGeneration = undefined;
|
|
2154
|
+
subscription.scope = this.replicaScope;
|
|
2155
|
+
this.handlers.set(subscription.id, (message) => {
|
|
2156
|
+
const scope = subscription.scope ?? this.replicaScope;
|
|
2157
|
+
void this.handleQueryMessage(subscription, message, scope)
|
|
2158
|
+
.catch(() => this.replica.setFreshness("verifying"));
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
for (const subscription of this.replicaSubscriptions.values()) {
|
|
2162
|
+
this.handlers.delete(subscription.id);
|
|
2163
|
+
this.pendingReplicaOpens.delete(subscription);
|
|
2164
|
+
subscription.id = randomID();
|
|
2165
|
+
subscription.socketGeneration = undefined;
|
|
2166
|
+
subscription.scope = this.replicaScope;
|
|
2167
|
+
this.handlers.set(subscription.id, (message) => {
|
|
2168
|
+
const scope = subscription.scope ?? this.replicaScope;
|
|
2169
|
+
void this.handleReplicaMessage(subscription, message, scope)
|
|
2170
|
+
.catch(() => this.replica.setFreshness("verifying"));
|
|
2171
|
+
});
|
|
2173
2172
|
}
|
|
2174
|
-
const generation = this.queryCacheGeneration;
|
|
2175
|
-
queueMicrotask(() => {
|
|
2176
|
-
if (this.queryCacheGeneration !== generation || this.queryCacheDirective?.scope !== directive.scope)
|
|
2177
|
-
return;
|
|
2178
|
-
void store.write({
|
|
2179
|
-
scope: directive.scope,
|
|
2180
|
-
path: subscription.path,
|
|
2181
|
-
args: subscription.args,
|
|
2182
|
-
result: message.result,
|
|
2183
|
-
revision: message.cacheRevision,
|
|
2184
|
-
maxAgeMs: directive.maxAgeMs,
|
|
2185
|
-
}).catch(() => undefined);
|
|
2186
|
-
});
|
|
2187
|
-
subscription.cachedRevision = message.cacheRevision;
|
|
2188
|
-
}
|
|
2189
|
-
deleteCachedQuery(subscription) {
|
|
2190
|
-
const store = this.queryCache;
|
|
2191
|
-
const directive = this.queryCacheDirective;
|
|
2192
|
-
if (!store || !directive)
|
|
2193
|
-
return;
|
|
2194
|
-
void store.delete(directive.scope, subscription.path, subscription.args).catch(() => undefined);
|
|
2195
2173
|
}
|
|
2196
2174
|
emitTelemetryFromCall(kind, id, path, outcome, clientSentAtMs, serverTrace, error) {
|
|
2197
2175
|
const clientReceivedAtMs = nowMs();
|
|
@@ -2240,6 +2218,43 @@ export class GonvexClient {
|
|
|
2240
2218
|
device: event.device ?? browserTelemetryInfo(),
|
|
2241
2219
|
});
|
|
2242
2220
|
}
|
|
2221
|
+
/** Send a bounded native error-telemetry frame using authenticated connection attribution. */
|
|
2222
|
+
reportError(type, payload) {
|
|
2223
|
+
return this.sendNativeError(type, payload);
|
|
2224
|
+
}
|
|
2225
|
+
sendNativeError(type, payload) {
|
|
2226
|
+
if (this.manuallyClosed)
|
|
2227
|
+
return Promise.reject(new Error("Gonvex client is closed"));
|
|
2228
|
+
this.connect();
|
|
2229
|
+
const id = randomID();
|
|
2230
|
+
return new Promise((resolve, reject) => {
|
|
2231
|
+
const timer = setTimeout(() => {
|
|
2232
|
+
this.handlers.delete(id);
|
|
2233
|
+
reject(new Error("native error telemetry timed out"));
|
|
2234
|
+
}, 10_000);
|
|
2235
|
+
this.handlers.set(id, (message) => {
|
|
2236
|
+
if (message.type !== "error.ack")
|
|
2237
|
+
return;
|
|
2238
|
+
clearTimeout(timer);
|
|
2239
|
+
this.handlers.delete(id);
|
|
2240
|
+
if (message.error)
|
|
2241
|
+
reject(new Error(message.error));
|
|
2242
|
+
else
|
|
2243
|
+
resolve();
|
|
2244
|
+
});
|
|
2245
|
+
if (type === "register") {
|
|
2246
|
+
const registration = payload;
|
|
2247
|
+
this.send({ type: "error.register", id, release: registration.release, environment: registration.environment });
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
if (type === "heartbeat") {
|
|
2251
|
+
this.send({ type: "error.heartbeat", id });
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
const events = payload.events ?? [];
|
|
2255
|
+
this.send({ type: "error.envelope", id, events });
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2243
2258
|
sendAuth(force, options = {}) {
|
|
2244
2259
|
if (!force && !this.auth.token && !this.auth.tenant && !this.auth.project && !this.auth.fetchToken)
|
|
2245
2260
|
return;
|
|
@@ -2254,16 +2269,36 @@ export class GonvexClient {
|
|
|
2254
2269
|
this.sendAuthFrame();
|
|
2255
2270
|
}
|
|
2256
2271
|
sendAuthFrame() {
|
|
2272
|
+
const id = randomID();
|
|
2273
|
+
this.managedAuthAttempt?.ids.add(id);
|
|
2257
2274
|
this.sendNow({
|
|
2258
2275
|
type: "auth",
|
|
2259
|
-
id
|
|
2276
|
+
id,
|
|
2260
2277
|
token: this.auth.token,
|
|
2261
2278
|
project: this.auth.project,
|
|
2262
2279
|
tenant: this.auth.tenant,
|
|
2280
|
+
controlOnly: !this.auth.tenant,
|
|
2263
2281
|
device: browserTelemetryInfo(),
|
|
2264
|
-
capabilities: {
|
|
2282
|
+
capabilities: { replicaReadyMany: 1, replicaWatermark: 1, queryPagePatch: 1, queryObjectPatch: 1, queryOrderDelta: 1, queryFanout: 1, queryResultBatch: 1 },
|
|
2265
2283
|
});
|
|
2266
2284
|
}
|
|
2285
|
+
settleManagedAuthAttempt(id, error) {
|
|
2286
|
+
const attempt = this.managedAuthAttempt;
|
|
2287
|
+
if (!attempt || !attempt.ids.has(id))
|
|
2288
|
+
return;
|
|
2289
|
+
this.managedAuthAttempt = undefined;
|
|
2290
|
+
if (error)
|
|
2291
|
+
attempt.reject(new GonvexClientError(error, { code: "auth" }));
|
|
2292
|
+
else
|
|
2293
|
+
attempt.resolve();
|
|
2294
|
+
}
|
|
2295
|
+
cancelManagedAuthAttempt(message) {
|
|
2296
|
+
const attempt = this.managedAuthAttempt;
|
|
2297
|
+
if (!attempt)
|
|
2298
|
+
return;
|
|
2299
|
+
this.managedAuthAttempt = undefined;
|
|
2300
|
+
attempt.reject(new GonvexClientError(message, { code: "auth" }));
|
|
2301
|
+
}
|
|
2267
2302
|
// Tokens from a fetcher are typically short-lived while the socket (and any
|
|
2268
2303
|
// disconnect gap) can span hours: replaying the token that was current at
|
|
2269
2304
|
// setAuth time guarantees an auth.error after a long sleep. authInFlight is
|
|
@@ -2323,7 +2358,7 @@ export class GonvexClient {
|
|
|
2323
2358
|
clearTimeout(this.authWatchdogTimer);
|
|
2324
2359
|
this.authWatchdogTimer = undefined;
|
|
2325
2360
|
}
|
|
2326
|
-
this.
|
|
2361
|
+
this.quarantineReplicaScope();
|
|
2327
2362
|
this.notifyAuthError(error);
|
|
2328
2363
|
this.flushPendingMessages();
|
|
2329
2364
|
}
|
|
@@ -2332,9 +2367,9 @@ export class GonvexClient {
|
|
|
2332
2367
|
handler(error);
|
|
2333
2368
|
}
|
|
2334
2369
|
}
|
|
2335
|
-
// A lost auth reply (
|
|
2370
|
+
// A lost auth reply (for example, during a module-generation swap) that dropped
|
|
2336
2371
|
// in-flight responses while the socket stayed up) used to leave
|
|
2337
|
-
// authInFlight stuck true forever: every later
|
|
2372
|
+
// authInFlight stuck true forever: every later reducer/subscription
|
|
2338
2373
|
// queued into pendingMessages and was never sent — no error, no timeout,
|
|
2339
2374
|
// and the server never saw the call. Re-issue auth if no reply arrives.
|
|
2340
2375
|
armAuthWatchdog() {
|
|
@@ -2415,8 +2450,35 @@ function replaceRowsAtPath(result, path, rows, scalar) {
|
|
|
2415
2450
|
const current = result[head];
|
|
2416
2451
|
return { ...result, [head]: replaceRowsAtPath(current ?? null, tail, rows, scalar) };
|
|
2417
2452
|
}
|
|
2453
|
+
function replaceOfflineLiveQueryMetadata(result, path, offline) {
|
|
2454
|
+
if (path.length === 0 || !isJsonRecord(result))
|
|
2455
|
+
return result;
|
|
2456
|
+
const [head, ...tail] = path;
|
|
2457
|
+
if (tail.length === 0) {
|
|
2458
|
+
const next = { ...result };
|
|
2459
|
+
delete next.total;
|
|
2460
|
+
delete next.offset;
|
|
2461
|
+
delete next.limit;
|
|
2462
|
+
if (offline.total !== undefined)
|
|
2463
|
+
next.total = offline.total;
|
|
2464
|
+
if (offline.offset !== undefined)
|
|
2465
|
+
next.offset = offline.offset;
|
|
2466
|
+
if (offline.limit !== undefined)
|
|
2467
|
+
next.limit = offline.limit;
|
|
2468
|
+
return next;
|
|
2469
|
+
}
|
|
2470
|
+
const current = result[head];
|
|
2471
|
+
return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
|
|
2472
|
+
}
|
|
2418
2473
|
function querySubscriptionKey(ref, args) {
|
|
2419
|
-
|
|
2474
|
+
const contract = {
|
|
2475
|
+
scope: ref.scope ?? "tenant",
|
|
2476
|
+
delivery: ref.delivery ?? "oneShot",
|
|
2477
|
+
live: ref.live
|
|
2478
|
+
? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
|
|
2479
|
+
: null,
|
|
2480
|
+
};
|
|
2481
|
+
return `${ref.path}\u0000${stableStringify(args)}\u0000${stableStringify(contract)}`;
|
|
2420
2482
|
}
|
|
2421
2483
|
function countPendingCalls(calls, kind) {
|
|
2422
2484
|
let count = 0;
|
|
@@ -2426,11 +2488,11 @@ function countPendingCalls(calls, kind) {
|
|
|
2426
2488
|
}
|
|
2427
2489
|
return count;
|
|
2428
2490
|
}
|
|
2429
|
-
function
|
|
2491
|
+
function isQueueableReducerError(error) {
|
|
2430
2492
|
return error instanceof GonvexClientError
|
|
2431
2493
|
&& (error.code === "disconnected" || error.code === "timeout");
|
|
2432
2494
|
}
|
|
2433
|
-
function
|
|
2495
|
+
function reducerErrorMessage(error) {
|
|
2434
2496
|
return error instanceof Error ? error.message : String(error);
|
|
2435
2497
|
}
|
|
2436
2498
|
function stableStringify(value) {
|
|
@@ -2462,15 +2524,15 @@ function utf8KeyCompare(left, right) {
|
|
|
2462
2524
|
function sameRevision(left, right) {
|
|
2463
2525
|
return !!right && left.epoch === right.epoch && left.sequence === right.sequence;
|
|
2464
2526
|
}
|
|
2465
|
-
function
|
|
2527
|
+
function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirection) {
|
|
2466
2528
|
const kept = [];
|
|
2467
2529
|
const seen = new Set();
|
|
2468
2530
|
let bytes = 0;
|
|
2469
|
-
for (const row of
|
|
2470
|
-
const key =
|
|
2531
|
+
for (const row of sortReplicaRows(rows, orderBy, orderDirection)) {
|
|
2532
|
+
const key = replicaRowKeyValue(row, keyField);
|
|
2471
2533
|
if (!key || seen.has(key))
|
|
2472
2534
|
continue;
|
|
2473
|
-
const size =
|
|
2535
|
+
const size = replicaJSONSize(row);
|
|
2474
2536
|
if (maxRows && kept.length >= maxRows)
|
|
2475
2537
|
break;
|
|
2476
2538
|
if (maxBytes && bytes + size > maxBytes)
|
|
@@ -2481,22 +2543,22 @@ function boundSyncRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirectio
|
|
|
2481
2543
|
}
|
|
2482
2544
|
return kept;
|
|
2483
2545
|
}
|
|
2484
|
-
function
|
|
2546
|
+
function applyReplicaDelta(current, keyField, upserts, deleted, maxRows, maxBytes, orderBy, orderDirection) {
|
|
2485
2547
|
const deletedSet = new Set(deleted);
|
|
2486
|
-
const upsertKeys = new Set(upserts.map((row) =>
|
|
2548
|
+
const upsertKeys = new Set(upserts.map((row) => replicaRowKeyValue(row, keyField)).filter(Boolean));
|
|
2487
2549
|
const remainder = current.filter((row) => {
|
|
2488
|
-
const key =
|
|
2550
|
+
const key = replicaRowKeyValue(row, keyField);
|
|
2489
2551
|
return key && !deletedSet.has(key) && !upsertKeys.has(key);
|
|
2490
2552
|
});
|
|
2491
|
-
return
|
|
2553
|
+
return boundReplicaRows([...upserts, ...remainder], keyField, maxRows, maxBytes, orderBy, orderDirection);
|
|
2492
2554
|
}
|
|
2493
|
-
function
|
|
2555
|
+
function sortReplicaRows(rows, orderBy, orderDirection) {
|
|
2494
2556
|
if (!orderBy)
|
|
2495
2557
|
return rows;
|
|
2496
2558
|
const direction = orderDirection === "asc" ? 1 : -1;
|
|
2497
2559
|
return [...rows].sort((left, right) => {
|
|
2498
|
-
const leftValue =
|
|
2499
|
-
const rightValue =
|
|
2560
|
+
const leftValue = replicaOrderValue(left, orderBy);
|
|
2561
|
+
const rightValue = replicaOrderValue(right, orderBy);
|
|
2500
2562
|
if (leftValue === rightValue)
|
|
2501
2563
|
return 0;
|
|
2502
2564
|
if (leftValue === null)
|
|
@@ -2506,19 +2568,19 @@ function sortClientSyncRows(rows, orderBy, orderDirection) {
|
|
|
2506
2568
|
return leftValue < rightValue ? -direction : direction;
|
|
2507
2569
|
});
|
|
2508
2570
|
}
|
|
2509
|
-
function
|
|
2571
|
+
function replicaOrderValue(value, orderBy) {
|
|
2510
2572
|
if (!value || Array.isArray(value) || typeof value !== "object")
|
|
2511
2573
|
return null;
|
|
2512
2574
|
const candidate = value[orderBy];
|
|
2513
2575
|
return typeof candidate === "string" || typeof candidate === "number" ? candidate : null;
|
|
2514
2576
|
}
|
|
2515
|
-
function
|
|
2577
|
+
function replicaRowKeyValue(value, keyField) {
|
|
2516
2578
|
if (!value || Array.isArray(value) || typeof value !== "object")
|
|
2517
2579
|
return "";
|
|
2518
2580
|
const key = value[keyField];
|
|
2519
2581
|
return key === null || key === undefined ? "" : String(key);
|
|
2520
2582
|
}
|
|
2521
|
-
function
|
|
2583
|
+
function replicaJSONSize(value) {
|
|
2522
2584
|
return new TextEncoder().encode(stableStringify(value)).byteLength;
|
|
2523
2585
|
}
|
|
2524
2586
|
function applyKeyedPatch(previous, patch) {
|
|
@@ -2586,11 +2648,6 @@ function queryPatchRowKey(value) {
|
|
|
2586
2648
|
const candidate = value._id ?? value.id;
|
|
2587
2649
|
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
|
|
2588
2650
|
}
|
|
2589
|
-
export class ConvexReactClient extends GonvexClient {
|
|
2590
|
-
constructor(url, options = {}) {
|
|
2591
|
-
super(toWebSocketURL(url, options.project), options);
|
|
2592
|
-
}
|
|
2593
|
-
}
|
|
2594
2651
|
function authFromOptions(options) {
|
|
2595
2652
|
return {
|
|
2596
2653
|
project: options.project,
|
|
@@ -2611,10 +2668,13 @@ function normalizeQuerySubscriptionRetentionMs(value) {
|
|
|
2611
2668
|
function authIdentityKey(auth) {
|
|
2612
2669
|
if (!auth.tenant)
|
|
2613
2670
|
return "";
|
|
2614
|
-
if (auth.token)
|
|
2615
|
-
|
|
2671
|
+
if (auth.token) {
|
|
2672
|
+
const tokenIdentity = authIdentityKeyFromToken(auth);
|
|
2673
|
+
if (tokenIdentity)
|
|
2674
|
+
return tokenIdentity;
|
|
2675
|
+
}
|
|
2616
2676
|
// Token-free fallback: an explicit identity hint carries the same claims a
|
|
2617
|
-
// token would supply, so both paths derive the same key for the same
|
|
2677
|
+
// token would supply, so both paths derive the same key for the same Account.
|
|
2618
2678
|
const hint = auth.identity;
|
|
2619
2679
|
if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
|
|
2620
2680
|
return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
|
|
@@ -2649,70 +2709,53 @@ function sameAuthTokenIdentity(left, right) {
|
|
|
2649
2709
|
const rightIdentity = authIdentityKey(right);
|
|
2650
2710
|
return leftIdentity !== "" && leftIdentity === rightIdentity;
|
|
2651
2711
|
}
|
|
2652
|
-
function
|
|
2712
|
+
function reducerOutboxScope(url, auth, ephemeralScope) {
|
|
2653
2713
|
const identity = authIdentityKey(auth);
|
|
2654
2714
|
if (identity)
|
|
2655
2715
|
return ["identity", url, identity].join("\u0000");
|
|
2656
2716
|
if (auth.token || auth.identity || auth.fetchToken) {
|
|
2657
2717
|
// Opaque tokens (or credentials installed before tenant selection) do not
|
|
2658
|
-
// expose a stable
|
|
2659
|
-
// queue semantics without ever restoring those rows under another
|
|
2718
|
+
// expose a stable Account key. A per-client scope preserves current-session
|
|
2719
|
+
// queue semantics without ever restoring those rows under another Account.
|
|
2660
2720
|
return ["ephemeral-auth", url, ephemeralScope].join("\u0000");
|
|
2661
2721
|
}
|
|
2662
2722
|
// Anonymous/dev-auth clients still need a stable namespace, but it must be
|
|
2663
2723
|
// isolated by deployment and tenant. Once an authenticated identity is
|
|
2664
2724
|
// installed, applyAuth switches away from this scope before restoring or
|
|
2665
|
-
// sending its durable
|
|
2725
|
+
// sending its durable reducers.
|
|
2666
2726
|
return ["anonymous", url, auth.project ?? "", auth.tenant ?? ""].join("\u0000");
|
|
2667
2727
|
}
|
|
2668
2728
|
function isEphemeralOutboxScope(scope) {
|
|
2669
2729
|
return scope.startsWith("ephemeral-auth\u0000");
|
|
2670
2730
|
}
|
|
2671
|
-
function queryCacheDirectiveFromAuthResult(result) {
|
|
2672
|
-
if (!isJsonRecord(result))
|
|
2673
|
-
return undefined;
|
|
2674
|
-
return validQueryCacheDirective(result.queryCache) ? result.queryCache : undefined;
|
|
2675
|
-
}
|
|
2676
|
-
function validQueryCacheDirective(value) {
|
|
2677
|
-
if (!isJsonRecord(value))
|
|
2678
|
-
return false;
|
|
2679
|
-
return value.protocolVersion === 1
|
|
2680
|
-
&& typeof value.scope === "string"
|
|
2681
|
-
&& value.scope.length >= 16
|
|
2682
|
-
&& (value.syncScope === undefined
|
|
2683
|
-
|| (typeof value.syncScope === "string" && value.syncScope.length >= 16))
|
|
2684
|
-
&& typeof value.epoch === "string"
|
|
2685
|
-
&& value.epoch.length >= 16
|
|
2686
|
-
&& typeof value.maxAgeMs === "number"
|
|
2687
|
-
&& Number.isFinite(value.maxAgeMs)
|
|
2688
|
-
&& value.maxAgeMs > 0;
|
|
2689
|
-
}
|
|
2690
|
-
/**
|
|
2691
|
-
* The scope under which sync collections are persisted and resumed. Newer
|
|
2692
|
-
* runtimes send a visibility-only `syncScope` that survives deploys (the
|
|
2693
|
-
* authoritative reconcile on resume guarantees correctness across code
|
|
2694
|
-
* changes); older runtimes only send the bundle-epoch `scope`.
|
|
2695
|
-
*/
|
|
2696
|
-
function syncPersistenceScope(directive) {
|
|
2697
|
-
return typeof directive.syncScope === "string" && directive.syncScope.length >= 16
|
|
2698
|
-
? directive.syncScope
|
|
2699
|
-
: directive.scope;
|
|
2700
|
-
}
|
|
2701
2731
|
function isJsonRecord(value) {
|
|
2702
2732
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2703
2733
|
}
|
|
2734
|
+
function replicaDirectiveFromAuthResult(result) {
|
|
2735
|
+
if (!isJsonRecord(result) || !isJsonRecord(result.replica))
|
|
2736
|
+
return undefined;
|
|
2737
|
+
const directive = result.replica;
|
|
2738
|
+
if (directive.protocolVersion !== 1
|
|
2739
|
+
|| typeof directive.scope !== "string"
|
|
2740
|
+
|| typeof directive.visibilityScope !== "string"
|
|
2741
|
+
|| typeof directive.epoch !== "string")
|
|
2742
|
+
return undefined;
|
|
2743
|
+
return {
|
|
2744
|
+
protocolVersion: 1,
|
|
2745
|
+
scope: directive.scope,
|
|
2746
|
+
visibilityScope: directive.visibilityScope,
|
|
2747
|
+
epoch: directive.epoch,
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
function developerSessionTokenFromAuthResult(result) {
|
|
2751
|
+
if (!isJsonRecord(result))
|
|
2752
|
+
return undefined;
|
|
2753
|
+
const token = result.developerSessionToken;
|
|
2754
|
+
return typeof token === "string" && token.startsWith("gvx_dev_") ? token : undefined;
|
|
2755
|
+
}
|
|
2704
2756
|
function hasOwn(value, key) {
|
|
2705
2757
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
2706
2758
|
}
|
|
2707
|
-
function toWebSocketURL(url, project) {
|
|
2708
|
-
const wsURL = url.startsWith("ws://") || url.startsWith("wss://")
|
|
2709
|
-
? new URL(url)
|
|
2710
|
-
: new URL(`${url.replace(/^http:/, "ws:").replace(/^https:/, "wss:").replace(/\/$/, "")}/ws`);
|
|
2711
|
-
if (project && !wsURL.searchParams.has("project")) {
|
|
2712
|
-
wsURL.searchParams.set("project", project);
|
|
2713
|
-
}
|
|
2714
|
-
return wsURL.toString();
|
|
2715
|
-
}
|
|
2716
2759
|
function randomID() {
|
|
2717
2760
|
const randomUUID = globalThis.crypto?.randomUUID;
|
|
2718
2761
|
if (randomUUID)
|
|
@@ -2728,12 +2771,6 @@ function nowMs() {
|
|
|
2728
2771
|
}
|
|
2729
2772
|
return Date.now();
|
|
2730
2773
|
}
|
|
2731
|
-
function queryCacheReadTimeout(value) {
|
|
2732
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
2733
|
-
return defaultQueryCacheReadTimeoutMs;
|
|
2734
|
-
}
|
|
2735
|
-
return value;
|
|
2736
|
-
}
|
|
2737
2774
|
function browserTelemetryInfo() {
|
|
2738
2775
|
const navigatorValue = globalThis.navigator;
|
|
2739
2776
|
if (!navigatorValue)
|