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