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