@gonvex/client 0.1.32 → 0.3.1

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