@gonvex/client 0.1.31 → 0.3.0

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