@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.21

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 (38) hide show
  1. package/README.md +68 -2
  2. package/dist/browser-upgrades.d.ts +19 -0
  3. package/dist/browser-upgrades.js +157 -0
  4. package/dist/browser-upgrades.js.map +1 -0
  5. package/dist/client-upgrades.d.ts +26 -0
  6. package/dist/client-upgrades.js +41 -0
  7. package/dist/client-upgrades.js.map +1 -0
  8. package/dist/control.d.ts +2 -0
  9. package/dist/control.js +1 -0
  10. package/dist/control.js.map +1 -1
  11. package/dist/index.d.ts +348 -9
  12. package/dist/index.js +1576 -192
  13. package/dist/index.js.map +1 -1
  14. package/dist/indexeddb-read-view.d.ts +24 -0
  15. package/dist/indexeddb-read-view.js +164 -0
  16. package/dist/indexeddb-read-view.js.map +1 -0
  17. package/dist/indexeddb-replica.d.ts +41 -3
  18. package/dist/indexeddb-replica.js +405 -111
  19. package/dist/indexeddb-replica.js.map +1 -1
  20. package/dist/local-replica.d.ts +127 -4
  21. package/dist/local-replica.js +836 -85
  22. package/dist/local-replica.js.map +1 -1
  23. package/dist/outbox.d.ts +118 -10
  24. package/dist/outbox.js +391 -89
  25. package/dist/outbox.js.map +1 -1
  26. package/dist/paged-read-view.d.ts +13 -0
  27. package/dist/paged-read-view.js +60 -0
  28. package/dist/paged-read-view.js.map +1 -0
  29. package/dist/query-expression.d.ts +39 -2
  30. package/dist/query-expression.js +106 -5
  31. package/dist/query-expression.js.map +1 -1
  32. package/dist/replica-integrity.d.ts +2 -0
  33. package/dist/replica-integrity.js +10 -0
  34. package/dist/replica-integrity.js.map +1 -1
  35. package/dist/replica-record.d.ts +14 -0
  36. package/dist/replica-record.js +46 -0
  37. package/dist/replica-record.js.map +1 -0
  38. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -1,15 +1,20 @@
1
- import { replicaHashesDigest, replicaRowsHashes } from "./replica-integrity.js";
1
+ import { readIsCovered, primaryReadKeys } from "@gonvex/local-runtime/read-view";
2
+ import { applyReplicaHashDelta, replicaHashesDigest, replicaRowsHashes } from "./replica-integrity.js";
2
3
  import { GonvexErrorReporter } from "./error-reporter.js";
3
4
  export { GonvexErrorReporter } from "./error-reporter.js";
4
5
  import { optimisticPatchesFromReference, } from "./optimistic.js";
5
- import { createReducerOutbox, } from "./outbox.js";
6
+ import { createReducerOutbox, DEFAULT_OUTBOX_MAX_BACKOFF_MS, outboxBackoffMs, outboxEntryIsLive, } from "./outbox.js";
6
7
  import { LocalReplica, } from "./local-replica.js";
7
- import { runOfflineLiveQuery } from "./query-expression.js";
8
+ import { runOfflineLiveQuery, liveScopeCoverage } from "./query-expression.js";
8
9
  export * from "./error-reporter.js";
9
10
  export * from "./optimistic.js";
10
11
  export * from "./outbox.js";
11
12
  export * from "./kv-stores.js";
13
+ export * from "./client-upgrades.js";
14
+ export * from "./browser-upgrades.js";
12
15
  export * from "./signals.js";
16
+ export * from "./paged-read-view.js";
17
+ export { mergeReplicaRecord } from './replica-record.js';
13
18
  export * from "./external-auth.js";
14
19
  // Keep the mutable LocalReplica implementation private to GonvexClient. The
15
20
  // public package exposes only the read-only view plus storage/value types.
@@ -39,6 +44,7 @@ function createLocalReplicaView(replica) {
39
44
  cursor: () => replica.cursor(),
40
45
  freshness: () => replica.freshness(),
41
46
  version: () => replica.version(),
47
+ entityVersion: (entity, id) => replica.entityVersion(entity, id),
42
48
  subscribe: (listener) => replica.subscribe(listener),
43
49
  hasPendingCommand: (commandId) => replica.hasPendingCommand(commandId),
44
50
  getWindow: (signature) => replica.getWindow(signature),
@@ -49,6 +55,7 @@ function createLocalReplicaView(replica) {
49
55
  entityRows: (entity) => replica.entityRows(entity),
50
56
  entityCompleteness: (entity) => replica.entityCompleteness(entity),
51
57
  liveQuery: (signature) => replica.liveQuery(signature),
58
+ liveQuerySnapshot: (signature) => replica.liveQuerySnapshot(signature),
52
59
  collectionState: (signature) => replica.collectionState(signature),
53
60
  hasLiveQuery: (signature) => replica.hasLiveQuery(signature),
54
61
  snapshot: () => replica.snapshot(),
@@ -89,14 +96,30 @@ export class GonvexClientError extends Error {
89
96
  code;
90
97
  path;
91
98
  operation;
99
+ /**
100
+ * For `server` Reducer errors: the runtime's classification, or a legacy
101
+ * inference when an older runtime sent none. Undefined means a legacy
102
+ * runtime's unclassified error, which is handled as a rejection.
103
+ */
104
+ errorClass;
105
+ /** True when the same call (same idempotency key) may succeed later. */
106
+ retryable;
92
107
  constructor(message, options) {
93
108
  super(message);
94
109
  this.name = "GonvexClientError";
95
110
  this.code = options.code;
96
111
  this.path = options.path;
97
112
  this.operation = options.operation;
113
+ if (options.errorClass)
114
+ this.errorClass = options.errorClass;
115
+ if (options.retryable !== undefined)
116
+ this.retryable = options.retryable;
98
117
  }
99
118
  }
119
+ /** Default row bound of `localDependencyResidency: "bounded"`. */
120
+ export const DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS = 1000;
121
+ export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
122
+ export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
100
123
  export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
101
124
  export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
102
125
  export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
@@ -115,10 +138,34 @@ const maxReplicaBatchOpens = 256;
115
138
  // normally settle in a few milliseconds.
116
139
  export class GonvexClient {
117
140
  url;
141
+ localTableBindings = new Map();
142
+ localBinding;
143
+ localExecutor;
144
+ localStorage;
145
+ localIdentity;
146
+ localLane = Promise.resolve();
147
+ preparationLane = Promise.resolve();
148
+ localReplayScheduled = false;
149
+ waitingLocalEdits = 0;
150
+ replacingLocal = false;
151
+ unsubscribeLocal;
152
+ localCollectionClosers = [];
153
+ localCollectionKeys = new Set();
154
+ localExecutionTables = new Map();
155
+ reducerRejectionHandlers = new Set();
156
+ localFallbackHandlers = new Set();
157
+ residentDependencyTables;
158
+ outboxRetry;
159
+ unauthenticatedRetries = 0;
160
+ intentsSnapshotValue = [];
161
+ intentListeners = new Set();
162
+ intentsRefreshRunning = false;
163
+ intentsRefreshDirty = false;
118
164
  socket;
119
165
  handlers = new Map();
120
166
  querySubscriptions = new Map();
121
167
  replicaSubscriptions = new Map();
168
+ replicaMetadataListeners = new WeakSet();
122
169
  oneShotQueries = new Map();
123
170
  telemetryHandlers = new Set();
124
171
  pendingMessages = [];
@@ -128,6 +175,7 @@ export class GonvexClient {
128
175
  querySubscribeFlushTimer;
129
176
  serverCapabilities = {};
130
177
  activeArtifactHashValue = "";
178
+ publicTenant;
131
179
  auth = {};
132
180
  authInFlight = false;
133
181
  // Only the newest auth frame may change socket authorization. The runtime
@@ -145,6 +193,7 @@ export class GonvexClient {
145
193
  // or a fresh send cycle starts, so a bad token can't refresh-loop forever.
146
194
  authRetriedAfterError = false;
147
195
  authErrorHandlers = new Set();
196
+ developerCredentialHandlers = new Set();
148
197
  managedAuthAttempt;
149
198
  telemetryEnabled = false;
150
199
  querySubscriptionRetentionMs;
@@ -164,11 +213,19 @@ export class GonvexClient {
164
213
  hasAuthoritativeReplicaScope = false;
165
214
  replicaReady = Promise.resolve();
166
215
  replicaFrames = Promise.resolve();
216
+ queuedReplicaWatermark;
167
217
  processedReplicaWatermarkRevision = 0;
168
- pendingReplicaTransactions = [];
218
+ pendingReplicaFrames = [];
169
219
  unsubscribeOutbox;
220
+ sharedOutboxStore;
221
+ unsubscribePeerOutbox;
222
+ unsubscribePeerReplica;
223
+ peerRefreshScheduled = false;
224
+ peerRefreshDirty = false;
170
225
  unsubscribeBrowserOnline;
171
226
  drainingOutbox = false;
227
+ /** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
228
+ outboxDrainRequested = false;
172
229
  outboxDrainTimer;
173
230
  sessionScopeHandlers = new Set();
174
231
  errorReporter;
@@ -180,20 +237,68 @@ export class GonvexClient {
180
237
  pendingCalls = new Map();
181
238
  connectionStateHandlers = new Set();
182
239
  supportCommandHandlers = new Set();
240
+ pendingNativeErrors = [];
183
241
  isWebSocketConnected = false;
184
242
  hasEverConnected = false;
185
243
  connectionCount = 0;
186
244
  timeouts;
245
+ clientContract;
246
+ updateRequiredHandler;
247
+ updateRequired = false;
248
+ lastOnlineAtMs = 0;
187
249
  constructor(url, options = {}) {
188
250
  this.url = url;
251
+ if (options.public) {
252
+ if (!options.public.tenant.trim() || !options.project?.trim())
253
+ throw new Error("Public connections require project and tenant");
254
+ if (options.token || options.fetchToken || options.identity || options.tenant)
255
+ throw new Error("Public connections cannot include session credentials or a separate tenant");
256
+ this.publicTenant = options.public.tenant.trim();
257
+ options = { ...options, clientContract: options.clientContract ?? options.localRuntime?.clientContract, tenant: this.publicTenant, localRuntime: undefined, localReplica: undefined, outbox: { enabled: false }, telemetry: false };
258
+ }
259
+ this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
260
+ this.updateRequiredHandler = options.onUpdateRequired;
261
+ this.localBinding = options.localRuntime;
262
+ for (const table of this.localBinding?.tables ?? []) {
263
+ const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
264
+ && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
265
+ this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
266
+ columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
267
+ }
268
+ this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
269
+ this.residentDependencyTables = residentLocalDependencyTables(options.localRuntime, options.localDependencyResidency);
270
+ if (options.onLocalExecutionFallback)
271
+ this.localFallbackHandlers.add(options.onLocalExecutionFallback);
272
+ this.localStorage = options.localReplica?.storage;
189
273
  this.auth = authFromOptions(options);
190
274
  this.telemetryEnabled = options.telemetry === true;
191
275
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
192
276
  this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
277
+ this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
193
278
  this.reducerOutbox = createReducerOutbox(options.outbox);
194
- this.replica = new LocalReplica(options.localReplica?.storage);
279
+ this.outboxRetry = {
280
+ maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
281
+ maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
282
+ };
283
+ this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
195
284
  this.replicaView = createLocalReplicaView(this.replica);
285
+ if (this.localExecutor) {
286
+ let executionVersion = this.replica.executionVersion();
287
+ this.unsubscribeLocal = this.replica.subscribe(() => {
288
+ const next = this.replica.executionVersion();
289
+ if (next === executionVersion)
290
+ return;
291
+ executionVersion = next;
292
+ this.scheduleLocalReplay();
293
+ });
294
+ }
295
+ this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
296
+ this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
297
+ if (scope === this.replicaScope)
298
+ this.refreshPeerOutbox();
299
+ });
196
300
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
301
+ this.refreshIntents();
197
302
  void this.drainOutbox();
198
303
  });
199
304
  if (typeof globalThis.addEventListener === "function") {
@@ -214,7 +319,7 @@ export class GonvexClient {
214
319
  this.outboxScope = initialScope;
215
320
  this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
216
321
  this.outboxScopeGeneration += 1;
217
- this.replicaReady = this.replica.activateScope(this.replicaScope, true);
322
+ this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
218
323
  this.outboxReady = Promise.resolve();
219
324
  this.timeouts = {
220
325
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
@@ -229,6 +334,730 @@ export class GonvexClient {
229
334
  transport: (type, payload) => this.sendNativeError(type, payload),
230
335
  });
231
336
  }
337
+ if (this.localBinding)
338
+ this.setOutboxReady(this.restoreLocalSession(initialScope, this.outboxScopeGeneration));
339
+ }
340
+ /**
341
+ * Authoritative sync failures arrive after an offline call returned locally.
342
+ * The intent also stays in the outbox as `rejected` until it is discarded
343
+ * or retried, so a UI that mounts later can still show it.
344
+ */
345
+ onReducerRejection(listener) {
346
+ this.reducerRejectionHandlers.add(listener);
347
+ return () => this.reducerRejectionHandlers.delete(listener);
348
+ }
349
+ /**
350
+ * Observe every point where a locally executing Reducer degrades to a
351
+ * queued-only intent (no local result). The intent is still durable and is
352
+ * delivered to the server; this exists so apps can log and fix the missing
353
+ * local data. Returns an unsubscribe function.
354
+ */
355
+ onLocalExecutionFallback(listener) {
356
+ this.localFallbackHandlers.add(listener);
357
+ return () => { this.localFallbackHandlers.delete(listener); };
358
+ }
359
+ reportLocalFallback(path, intentId, phase, error) {
360
+ if (!this.localFallbackHandlers.size)
361
+ return;
362
+ const incomplete = error instanceof Error && error.name === "IncompleteReplicaError";
363
+ const table = incomplete
364
+ ? error.read?.table ?? /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1]
365
+ : undefined;
366
+ const event = {
367
+ path, intentId, phase, reason: incomplete ? "incompleteReplica" : "localError",
368
+ ...(table ? { table } : {}),
369
+ error: error instanceof Error ? error.message : String(error),
370
+ };
371
+ for (const listener of this.localFallbackHandlers) {
372
+ try {
373
+ listener(event);
374
+ }
375
+ catch { /* A listener must not break delivery. */ }
376
+ }
377
+ }
378
+ /** Every durable intent of the current identity, oldest first. */
379
+ async listIntents() {
380
+ await this.outboxReady;
381
+ return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
382
+ }
383
+ /**
384
+ * Synchronous snapshot for external stores (React `useSyncExternalStore`).
385
+ * Kept current while at least one {@link subscribeIntents} listener exists.
386
+ */
387
+ intentsSnapshot() {
388
+ return this.intentsSnapshotValue;
389
+ }
390
+ /** Observe intent changes; call {@link intentsSnapshot} for the new value. */
391
+ subscribeIntents(listener) {
392
+ this.intentListeners.add(listener);
393
+ if (this.intentListeners.size === 1)
394
+ this.refreshIntents();
395
+ return () => { this.intentListeners.delete(listener); };
396
+ }
397
+ /**
398
+ * Delivery status for one row from the current snapshot: `failed` or
399
+ * `rejected` when an intent touching it needs attention, `syncing` while one
400
+ * is still queued, otherwise undefined.
401
+ */
402
+ entityIntentStatus(entity, id) {
403
+ return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
404
+ }
405
+ /**
406
+ * Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
407
+ * original idempotency key is reused, so an attempt that had actually
408
+ * committed is replayed by the server rather than applied twice.
409
+ */
410
+ async retryIntent(id) {
411
+ await this.outboxReady;
412
+ const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
413
+ const scope = this.outboxScope;
414
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
415
+ if (!entry || scope !== this.outboxScope)
416
+ return false;
417
+ const updated = await this.reducerOutbox.retry(entry.id);
418
+ if (!updated)
419
+ return false;
420
+ this.optimisticOutboxEntryIds.set(id, updated.id);
421
+ if (!outboxEntryIsLive(entry)) {
422
+ // A rejection rolled the prediction back; restore it at its original
423
+ // position in the causal chain.
424
+ if (updated.localExecution && this.localExecutor)
425
+ await this.rebaseLocalEntriesLocked(true);
426
+ else
427
+ this.addOptimisticReducer(id, updated.patches ?? []);
428
+ }
429
+ return true;
430
+ }));
431
+ if (retried) {
432
+ this.unauthenticatedRetries = 0;
433
+ void this.drainOutbox();
434
+ }
435
+ return retried;
436
+ }
437
+ /**
438
+ * Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
439
+ * its optimistic prediction is removed and later local intents are rebased.
440
+ * Inflight and committed intents cannot be discarded. Discarding an intent
441
+ * whose earlier attempt timed out does not undo a commit that the server
442
+ * may already have applied; the replica then shows the server's truth.
443
+ */
444
+ async discardIntent(id) {
445
+ await this.outboxReady;
446
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
447
+ const scope = this.outboxScope;
448
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
449
+ if (!entry || scope !== this.outboxScope)
450
+ return false;
451
+ const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
452
+ if (!removed)
453
+ return false;
454
+ this.optimisticOutboxEntryIds.delete(id);
455
+ this.optimisticReducerIds.delete(id);
456
+ if (outboxEntryIsLive(removed)) {
457
+ this.replacingLocal = true;
458
+ try {
459
+ this.replica.rejectCommand(id);
460
+ }
461
+ finally {
462
+ this.replacingLocal = false;
463
+ }
464
+ if (removed.localExecution && this.localExecutor)
465
+ await this.rebaseLocalEntriesLocked(true);
466
+ }
467
+ return true;
468
+ }));
469
+ }
470
+ /**
471
+ * Discard the persisted Local Replica of the active identity (IndexedDB,
472
+ * Expo SQLite or any configured storage) and rehydrate it from the server.
473
+ *
474
+ * Online only: without an authenticated connection the call rejects with a
475
+ * `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
476
+ * user is never left with an empty cache that cannot be refilled. The saved
477
+ * offline session (identity and replica directive) is kept.
478
+ *
479
+ * By default the outbox is untouched: pending, failed and rejected intents
480
+ * stay durable, and the predictions of every live intent are re-applied
481
+ * immediately and recomputed as server data arrives. Active Replica
482
+ * Collections and Live Queries are re-requested without their old cursors,
483
+ * so the server sends full snapshots.
484
+ */
485
+ async resetLocalReplica(options = {}) {
486
+ const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
487
+ if (this.manuallyClosed)
488
+ throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
489
+ await this.outboxReady;
490
+ await this.replicaReady;
491
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
492
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
493
+ }
494
+ const keepOutbox = options.keepOutbox !== false;
495
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
496
+ const scope = this.outboxScope;
497
+ const replicaScope = this.replicaScope;
498
+ // Re-check under the lock: the connection may have dropped while waiting.
499
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
500
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
501
+ }
502
+ // Stop the server streaming into subscription ids we are about to retire.
503
+ for (const subscription of this.replicaSubscriptions.values()) {
504
+ if (subscription.socketGeneration !== undefined)
505
+ this.send({ type: "replica.close", id: subscription.id });
506
+ }
507
+ for (const subscription of this.querySubscriptions.values()) {
508
+ if (subscription.socketGeneration !== undefined)
509
+ this.send({ type: "query.unsubscribe", id: subscription.id });
510
+ }
511
+ this.pendingReplicaFrames.length = 0;
512
+ await this.replica.clear(replicaScope);
513
+ this.resetReplicaScopeState();
514
+ this.rotateSubscriptionScopes();
515
+ let discardedIntents = 0;
516
+ if (!keepOutbox) {
517
+ for (const entry of await this.reducerOutbox.list(scope)) {
518
+ if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
519
+ discardedIntents += 1;
520
+ }
521
+ }
522
+ // replica.clear() dropped every overlay. Restore the stored predictions
523
+ // of live intents now; local intents are re-executed by the replay that
524
+ // each arriving snapshot schedules.
525
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
526
+ this.replacingLocal = true;
527
+ try {
528
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
529
+ }
530
+ finally {
531
+ this.replacingLocal = false;
532
+ }
533
+ this.optimisticReducerIds.clear();
534
+ this.optimisticOutboxEntryIds.clear();
535
+ for (const entry of entries) {
536
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
537
+ if (entry.state !== "committed" && entry.patches?.length)
538
+ this.optimisticReducerIds.add(entry.idempotencyKey);
539
+ }
540
+ let resubscribed = 0;
541
+ if (scope === this.outboxScope && replicaScope === this.replicaScope) {
542
+ for (const subscription of this.replicaSubscriptions.values())
543
+ if (subscription.listeners.size > 0)
544
+ resubscribed += 1;
545
+ for (const subscription of this.querySubscriptions.values())
546
+ if (subscription.listeners.size > 0)
547
+ resubscribed += 1;
548
+ this.resumeQuerySubscriptions(true);
549
+ this.resumeReplicaSubscriptions();
550
+ }
551
+ this.refreshIntents();
552
+ return { discardedIntents, resubscribed };
553
+ }));
554
+ }
555
+ /** Outbox owners with durable entries, including identities that never returned. */
556
+ async listOutboxScopes() {
557
+ await this.outboxReady;
558
+ const current = this.outboxScope;
559
+ return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
560
+ }
561
+ /**
562
+ * Permanently delete another identity's durable intents. The active scope
563
+ * cannot be purged this way; use {@link discardIntent} for its entries.
564
+ */
565
+ async purgeOutboxScope(scope) {
566
+ await this.outboxReady;
567
+ if (scope === this.outboxScope)
568
+ throw new Error("Cannot purge the active outbox scope");
569
+ return this.reducerOutbox.purgeScope(scope);
570
+ }
571
+ /** Purge every outbox scope except the current identity's. Never runs automatically. */
572
+ async purgeForeignOutboxScopes() {
573
+ let removed = 0;
574
+ for (const scope of await this.listOutboxScopes()) {
575
+ if (!scope.current)
576
+ removed += await this.purgeOutboxScope(scope.scope);
577
+ }
578
+ return removed;
579
+ }
580
+ refreshIntents() {
581
+ if (this.intentListeners.size === 0 || this.manuallyClosed)
582
+ return;
583
+ this.intentsRefreshDirty = true;
584
+ if (this.intentsRefreshRunning)
585
+ return;
586
+ this.intentsRefreshRunning = true;
587
+ void (async () => {
588
+ try {
589
+ while (this.intentsRefreshDirty && !this.manuallyClosed) {
590
+ this.intentsRefreshDirty = false;
591
+ await this.outboxReady.catch(() => undefined);
592
+ const scope = this.outboxScope;
593
+ const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
594
+ if (!entries || scope !== this.outboxScope) {
595
+ this.intentsRefreshDirty ||= scope !== this.outboxScope;
596
+ continue;
597
+ }
598
+ this.publishIntents(entries.map(outboxIntentFromEntry));
599
+ }
600
+ }
601
+ finally {
602
+ this.intentsRefreshRunning = false;
603
+ }
604
+ })();
605
+ }
606
+ publishIntents(next) {
607
+ if (sameIntents(this.intentsSnapshotValue, next))
608
+ return;
609
+ this.intentsSnapshotValue = next;
610
+ for (const listener of this.intentListeners) {
611
+ try {
612
+ listener();
613
+ }
614
+ catch { /* A listener must not break delivery. */ }
615
+ }
616
+ }
617
+ async restoreLocalSession(scope, generation) {
618
+ if (!this.localBinding || !scope.startsWith("identity\u0000"))
619
+ return;
620
+ const saved = await this.localStorage?.loadSession?.(scope);
621
+ if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
622
+ return;
623
+ this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
624
+ this.localIdentity = saved.identity;
625
+ void this.localExecutor?.ready.catch(() => undefined);
626
+ await this.activateReplicaDirective(saved.directive);
627
+ this.replica.setFreshness("offline");
628
+ }
629
+ ensureLocalCollections(tables) {
630
+ if (!this.localBinding)
631
+ return;
632
+ const needed = new Set(tables);
633
+ for (const reference of this.localBinding.collections ?? []) {
634
+ if (!reference.replica || !needed.has(reference.replica.table))
635
+ continue;
636
+ const key = querySubscriptionKey(reference, {});
637
+ if (this.localCollectionKeys.has(key))
638
+ continue;
639
+ this.localCollectionKeys.add(key);
640
+ this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
641
+ }
642
+ }
643
+ hydrateMissingLocalTable(error) {
644
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
645
+ return;
646
+ // Worker errors preserve the public runtime name/message contract.
647
+ const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
648
+ if (table)
649
+ this.ensureLocalCollections([table]);
650
+ }
651
+ localSnapshot(includePending) {
652
+ const rows = this.replica.captureExecutionRows(includePending);
653
+ const tables = {};
654
+ for (const [table, binding] of this.localTableBindings) {
655
+ const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
656
+ tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
657
+ }
658
+ return { scope: this.replicaScope, tables };
659
+ }
660
+ localReadCoverage() {
661
+ return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
662
+ key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
663
+ complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
664
+ }])));
665
+ }
666
+ isUncoveredLocalRead(error) {
667
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
668
+ return false;
669
+ const read = error.read;
670
+ if (!read || read.limit === 0)
671
+ return false;
672
+ const known = this.localReadCoverage()[read.table];
673
+ // Disk can recover evicted primary keys or a complete registered slice.
674
+ // It cannot prove a scan over data that has never been delivered. Queue
675
+ // that intent for replay instead of waiting for unrelated cache writes.
676
+ return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
677
+ }
678
+ async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
679
+ if (this.localBinding?.mode === 'portable') {
680
+ try {
681
+ const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
682
+ let transaction;
683
+ try {
684
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
685
+ }
686
+ catch (error) {
687
+ if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
688
+ throw error;
689
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
690
+ }
691
+ // The read view already proved every read from the available replica
692
+ // windows or cached primary keys. Do not promote those bounded reads
693
+ // to whole-table subscriptions after a successful user edit. Missing
694
+ // coverage still opens its generated collection in the error path.
695
+ return transaction;
696
+ }
697
+ catch (error) {
698
+ // A speculative memory-only read may simply need IndexedDB. Its
699
+ // coordinated retry checks disk before deciding to request more data.
700
+ if (!residentOnly)
701
+ this.hydrateMissingLocalTable(error);
702
+ throw error;
703
+ }
704
+ }
705
+ if (!snapshot)
706
+ throw new Error('Legacy local executor requires a snapshot');
707
+ const known = this.localExecutionTables.get(path);
708
+ const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
709
+ let transaction;
710
+ try {
711
+ transaction = await this.localExecutor.execute(path, args, input, execution);
712
+ }
713
+ catch (error) {
714
+ this.hydrateMissingLocalTable(error);
715
+ if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
716
+ throw error;
717
+ // A different argument, tenant or workflow can read different tables.
718
+ // Retry against the SAME captured snapshot, never newer mixed-version rows.
719
+ try {
720
+ transaction = await this.localExecutor.execute(path, args, snapshot, execution);
721
+ }
722
+ catch (retryError) {
723
+ this.hydrateMissingLocalTable(retryError);
724
+ throw retryError;
725
+ }
726
+ }
727
+ this.ensureLocalCollections(transaction.readTables);
728
+ this.localExecutionTables.set(path, new Set([
729
+ ...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
730
+ ]));
731
+ return transaction;
732
+ }
733
+ inLocalLane(run) {
734
+ const job = this.localLane.then(run);
735
+ this.localLane = job.catch(() => undefined);
736
+ return job;
737
+ }
738
+ replaceLocalPredictions(commands) {
739
+ this.replica.replaceOptimistic(commands);
740
+ }
741
+ coordinateOutbox(lane, run) {
742
+ return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
743
+ }
744
+ refreshPeerOutbox() {
745
+ if (this.manuallyClosed)
746
+ return;
747
+ this.peerRefreshDirty = true;
748
+ if (this.peerRefreshScheduled)
749
+ return;
750
+ this.peerRefreshScheduled = true;
751
+ void (async () => {
752
+ await this.outboxReady;
753
+ do {
754
+ this.peerRefreshDirty = false;
755
+ await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
756
+ const scope = this.outboxScope;
757
+ await this.replica.synchronizeStorage();
758
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
759
+ if (this.manuallyClosed || scope !== this.outboxScope)
760
+ return;
761
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
762
+ this.optimisticReducerIds.clear();
763
+ this.optimisticOutboxEntryIds.clear();
764
+ for (const entry of entries) {
765
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
766
+ if (entry.patches?.length)
767
+ this.optimisticReducerIds.add(entry.idempotencyKey);
768
+ }
769
+ }));
770
+ } while (this.peerRefreshDirty && !this.manuallyClosed);
771
+ this.refreshIntents();
772
+ void this.drainOutbox();
773
+ })().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
774
+ }
775
+ scheduleLocalReplay() {
776
+ if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
777
+ return;
778
+ // Restore and peer notifications discover durable intents explicitly. A
779
+ // table arriving at startup cannot invalidate a prediction that does not exist.
780
+ if (this.optimisticOutboxEntryIds.size === 0)
781
+ return;
782
+ this.localReplayScheduled = true;
783
+ queueMicrotask(() => {
784
+ void (async () => {
785
+ await this.outboxReady;
786
+ await this.inLocalLane(() => this.rebaseLocalEntries());
787
+ })().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
788
+ });
789
+ }
790
+ reportLocalRejection(reducerId, path, error) {
791
+ const message = reducerErrorMessage(error);
792
+ for (const listener of this.reducerRejectionHandlers) {
793
+ try {
794
+ listener({ reducerId, path, error: message, errorClass: "rejected" });
795
+ }
796
+ catch { /* A listener must not break delivery. */ }
797
+ }
798
+ }
799
+ rebaseLocalEntries(afterRejection = false) {
800
+ return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
801
+ }
802
+ async rebaseLocalEntriesLocked(afterRejection = false) {
803
+ if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
804
+ return;
805
+ // The authoritative drain validates the pending chain in order. Keep its
806
+ // predictions while it progresses, then rebase once it settles or loses
807
+ // connectivity instead of replaying N + (N-1) + ... local transactions.
808
+ if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
809
+ return;
810
+ await this.replica.synchronizeStorage();
811
+ const scope = this.outboxScope;
812
+ const replicaScope = this.replicaScope;
813
+ // Rejected records keep their payload for the UI but are never predicted.
814
+ // Failed (parked) intents stay predicted until retried or discarded.
815
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
816
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
817
+ return;
818
+ if (entries.length === 0) {
819
+ this.replacingLocal = true;
820
+ try {
821
+ this.replaceLocalPredictions([]);
822
+ }
823
+ finally {
824
+ this.replacingLocal = false;
825
+ }
826
+ this.optimisticReducerIds.clear();
827
+ this.optimisticOutboxEntryIds.clear();
828
+ return;
829
+ }
830
+ const baseVersion = this.replica.executionVersion();
831
+ const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
832
+ const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
833
+ const replayPatches = [];
834
+ const commands = [];
835
+ for (const entry of entries) {
836
+ // Predictions can wait; a fresh user intent must not sit behind the
837
+ // entire durable backlog. Publish only a complete, coherent rebase.
838
+ if (this.waitingLocalEdits > 0) {
839
+ setTimeout(() => this.scheduleLocalReplay(), 0);
840
+ return;
841
+ }
842
+ let patches = entry.patches ?? [];
843
+ if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
844
+ const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
845
+ try {
846
+ const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
847
+ patches = transaction.patches;
848
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
849
+ return;
850
+ }
851
+ catch (error) {
852
+ // Re-execution is a prediction. Only an authoritative server rejection
853
+ // removes a durable intent; missing data or local validation can make
854
+ // its predicted transaction unavailable without rejecting the intent.
855
+ if (patches.length && scope === this.outboxScope && replicaScope === this.replicaScope)
856
+ this.reportLocalFallback(entry.path, entry.idempotencyKey, "replay", error);
857
+ patches = [];
858
+ }
859
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
860
+ return;
861
+ // Storage failures must abort rebasing, never masquerade as rejection.
862
+ await this.reducerOutbox.updateLocal(entry.id, patches, execution);
863
+ }
864
+ if (snapshot)
865
+ applyLocalPatches(snapshot, patches);
866
+ for (const patch of patches)
867
+ replayPatches.push(patch.op === 'delete'
868
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
869
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
870
+ commands.push({ commandId: entry.idempotencyKey, patches });
871
+ }
872
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
873
+ return;
874
+ if (baseVersion !== this.replica.executionVersion()) {
875
+ // A changing server base must not recursively occupy the local lane.
876
+ // Release it so user intents already queued can run before the retry.
877
+ setTimeout(() => this.scheduleLocalReplay(), 0);
878
+ return;
879
+ }
880
+ this.replacingLocal = true;
881
+ try {
882
+ this.replaceLocalPredictions(commands);
883
+ }
884
+ finally {
885
+ this.replacingLocal = false;
886
+ }
887
+ this.optimisticReducerIds.clear();
888
+ this.optimisticOutboxEntryIds.clear();
889
+ for (const entry of entries) {
890
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
891
+ }
892
+ for (const command of commands)
893
+ if (command.patches.length)
894
+ this.optimisticReducerIds.add(command.commandId);
895
+ }
896
+ async runLocalReducer(ref, args) {
897
+ args = structuredClone(args);
898
+ const admittedGeneration = this.outboxScopeGeneration;
899
+ await this.outboxReady;
900
+ await this.replicaReady;
901
+ if (!this.localExecutor || !this.localBinding)
902
+ throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
903
+ const reducerId = randomID();
904
+ const execution = this.localIdentity ? {
905
+ scope: this.replicaScope, commandId: reducerId, now: Date.now(),
906
+ intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
907
+ artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
908
+ } : undefined;
909
+ let preparedResult;
910
+ let preparedFailure;
911
+ const prepared = this.preparationLane.then(async () => {
912
+ if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
913
+ return;
914
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
915
+ return;
916
+ const base = this.replica.executionVersion();
917
+ const pending = this.replica.pendingReducerPatches();
918
+ try {
919
+ const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
920
+ if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
921
+ return;
922
+ preparedResult = { transaction, base, pending: JSON.stringify(pending) };
923
+ }
924
+ catch (error) {
925
+ preparedFailure = error; /* Recheck coverage under coordinated admission. */
926
+ }
927
+ });
928
+ this.preparationLane = prepared.catch(() => undefined);
929
+ this.waitingLocalEdits += 1;
930
+ let entered = false;
931
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
932
+ entered = true;
933
+ this.waitingLocalEdits -= 1;
934
+ await prepared;
935
+ if (admittedGeneration !== this.outboxScopeGeneration)
936
+ throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
937
+ if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
938
+ throw new Error("Restore an authenticated local session before editing offline");
939
+ if (this.manuallyClosed)
940
+ throw new Error("Gonvex client is closed");
941
+ if (this.updateRequired)
942
+ throw new Error("Application update required before editing");
943
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
944
+ throw new Error("Offline editing window expired. Reconnect before making more changes.");
945
+ const scope = this.outboxScope;
946
+ if (!execution || execution.scope !== this.replicaScope)
947
+ throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
948
+ const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
949
+ const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
950
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
951
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
952
+ // A fully resident prediction reads the already published atomic replica.
953
+ // Unrelated snapshots may still be persisting; waiting for those here
954
+ // makes a small edit inherit their disk latency. The shared intent lock
955
+ // and journal still order every edit. Preparation proves resident
956
+ // coverage, even when a published commit invalidates its result while
957
+ // acquiring the lock. Re-execute against the latest atomic resident
958
+ // snapshot below; only known peer changes or committed shared intents
959
+ // require synchronizing storage first.
960
+ const canExecuteResident = preparedResult
961
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
962
+ && !sharedEntries.some(entry => entry.state === "committed");
963
+ const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
964
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
965
+ && !sharedEntries.some(entry => entry.state === "committed");
966
+ if (!canExecuteResident && !uncoveredRead)
967
+ await this.replica.synchronizeStorage();
968
+ if (sharedEntries)
969
+ this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
970
+ const executionBaseVersion = this.replica.executionVersion();
971
+ let transaction;
972
+ let fallback;
973
+ try {
974
+ if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
975
+ transaction = preparedResult.transaction;
976
+ }
977
+ else if (this.localBinding.mode === 'portable') {
978
+ // A commit may invalidate preparation while the intent lock is being
979
+ // acquired. Read the newly published atomic replica again before
980
+ // joining disk work that arrived after synchronizeStorage completed.
981
+ try {
982
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
983
+ }
984
+ catch (error) {
985
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
986
+ throw error;
987
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
988
+ }
989
+ }
990
+ else {
991
+ transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
992
+ }
993
+ }
994
+ catch (error) {
995
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
996
+ throw error;
997
+ fallback = error;
998
+ }
999
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
1000
+ throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
1001
+ const patches = transaction?.patches ?? [];
1002
+ this.directOutboxReducerIds.add(reducerId);
1003
+ try {
1004
+ // A visible prediction must already have a durable intent. Otherwise a
1005
+ // reload immediately after paint can lose an offline edit. Local
1006
+ // execution still avoids a server round trip; disk admission is bounded
1007
+ // to this intent, rather than persisting the whole replica.
1008
+ const entry = await this.reducerOutbox.enqueue({
1009
+ scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
1010
+ // All local intents form one causal chain, including reads of rows
1011
+ // written by a previous intent, not only overlapping write patches.
1012
+ entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
1013
+ });
1014
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
1015
+ throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
1016
+ }
1017
+ this.optimisticOutboxEntryIds.set(reducerId, entry.id);
1018
+ this.replacingLocal = true;
1019
+ try {
1020
+ this.addOptimisticReducer(reducerId, patches);
1021
+ }
1022
+ finally {
1023
+ this.replacingLocal = false;
1024
+ }
1025
+ if (!transaction) {
1026
+ this.reportLocalFallback(ref.path, reducerId, "call", fallback);
1027
+ return { status: "queued", reducerId };
1028
+ }
1029
+ return transaction.result;
1030
+ }
1031
+ catch (error) {
1032
+ this.optimisticReducerIds.delete(reducerId);
1033
+ this.replica.rejectCommand(reducerId);
1034
+ throw error;
1035
+ }
1036
+ finally {
1037
+ this.directOutboxReducerIds.delete(reducerId);
1038
+ // This transaction already executed against all earlier predictions.
1039
+ // Appending it does not invalidate those predictions. Rebase only if
1040
+ // the authoritative execution snapshot changed during execution.
1041
+ // An uncovered read has no new data to replay against yet. Its
1042
+ // hydration subscription schedules replay when coverage arrives;
1043
+ // immediately retrying here only locks edits behind the same disk work.
1044
+ if (this.replica.executionVersion() !== executionBaseVersion)
1045
+ this.scheduleLocalReplay();
1046
+ void this.drainOutbox();
1047
+ }
1048
+ })).catch(error => {
1049
+ if (!entered)
1050
+ this.waitingLocalEdits -= 1;
1051
+ this.optimisticReducerIds.delete(reducerId);
1052
+ this.replica.rejectCommand(reducerId);
1053
+ throw error;
1054
+ });
1055
+ }
1056
+ /** Preload code for a mounted control without reading data or executing an intent. */
1057
+ prepareReducer(ref) {
1058
+ if (ref.localExecution !== 1 || this.manuallyClosed)
1059
+ return Promise.resolve();
1060
+ return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
232
1061
  }
233
1062
  /** The single normalized authoritative + optimistic application data store. */
234
1063
  get localReplica() {
@@ -245,19 +1074,33 @@ export class GonvexClient {
245
1074
  replicaEntities(entity, ids) {
246
1075
  return this.replica.entityBatch(entity, ids);
247
1076
  }
1077
+ /** Retain only the data an active view observes; cold rows remain on disk. */
1078
+ retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
1079
+ retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
248
1080
  /** Read rows and server-owned completeness for a persisted Replica Collection. */
249
1081
  replicaCollectionState(ref, args = {}) {
250
1082
  return this.replica.collectionState(this.replicaSignature(ref, args));
251
1083
  }
252
1084
  /** Run the generated Live Query plan over the bounded normalized cache. */
253
- offlineLiveQuery(ref, args = {}) {
1085
+ offlineLiveQuery(ref, args = {}, options = {}) {
254
1086
  if (!ref.live?.plan) {
255
1087
  return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
256
1088
  }
257
1089
  const queryArgs = isJsonRecord(args) ? args : {};
258
- return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity));
1090
+ return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), options.window === false ? { ...ref.live.plan, window: undefined, sort: undefined } : ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity), (table, id) => this.replica.entity(table, id), {
1091
+ context: {
1092
+ ...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
1093
+ ...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
1094
+ ...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
1095
+ },
1096
+ relationRows: (table, where) => {
1097
+ const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
1098
+ return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
1099
+ && readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
1100
+ },
1101
+ });
259
1102
  }
260
- /** Number of reducers waiting for a definitive server result. */
1103
+ /** Number of reducers still queued for delivery (excludes failed and rejected intents). */
261
1104
  async outboxCount() {
262
1105
  await this.outboxReady;
263
1106
  return this.reducerOutbox.count(this.outboxScope);
@@ -374,7 +1217,24 @@ export class GonvexClient {
374
1217
  this.authErrorHandlers.delete(handler);
375
1218
  };
376
1219
  }
1220
+ /**
1221
+ * Subscribe to rotated developer-mode reconnect credentials. The runtime
1222
+ * consumes each developer credential when it accepts a connection and
1223
+ * returns a single-use successor; the client already uses that successor
1224
+ * for its own reconnects. Auth providers subscribe so they can keep the
1225
+ * newest credential in tab-scoped storage and resume developer mode after a
1226
+ * page reload. The value is a secret: never log it or put it in a URL.
1227
+ */
1228
+ onDeveloperCredential(handler) {
1229
+ this.developerCredentialHandlers.add(handler);
1230
+ return () => {
1231
+ this.developerCredentialHandlers.delete(handler);
1232
+ };
1233
+ }
377
1234
  applyAuth(auth) {
1235
+ if (this.publicTenant && (auth.token || auth.fetchToken || auth.identity || auth.tenant !== undefined && auth.tenant !== this.publicTenant || auth.project !== undefined && auth.project !== this.auth.project)) {
1236
+ throw new Error("Public connection scope is fixed; create another client to sign in or change tenants");
1237
+ }
378
1238
  const nextAuth = { ...this.auth, ...auth };
379
1239
  const tokenScopeChanged = hasOwn(auth, "token")
380
1240
  && auth.token !== this.auth.token
@@ -388,7 +1248,7 @@ export class GonvexClient {
388
1248
  || (hasOwn(auth, "identity") && !sameAuthTokenIdentity(this.auth, nextAuth));
389
1249
  if (scopeMayChange) {
390
1250
  this.pendingMessages.length = 0;
391
- this.pendingReplicaTransactions.length = 0;
1251
+ this.pendingReplicaFrames.length = 0;
392
1252
  this.rejectPendingCalls((call) => new GonvexClientError(`Authentication scope changed while waiting for ${call.kind} ${call.path}`, { code: "superseded", path: call.path, operation: call.kind }));
393
1253
  for (const query of this.oneShotQueries.values()) {
394
1254
  if (query.timeoutTimer)
@@ -416,12 +1276,20 @@ export class GonvexClient {
416
1276
  }
417
1277
  }
418
1278
  connect() {
1279
+ if (this.updateRequired)
1280
+ return;
419
1281
  if (this.socket && this.socket.readyState <= WebSocket.OPEN)
420
1282
  return;
421
1283
  const isReconnect = this.socket !== undefined;
422
1284
  this.manuallyClosed = false;
423
1285
  const generation = ++this.socketGeneration;
424
- const socket = new WebSocket(this.url);
1286
+ let transportUrl = this.url;
1287
+ if (this.publicTenant) {
1288
+ const publicUrl = new URL(this.url);
1289
+ publicUrl.searchParams.set("public", "1");
1290
+ transportUrl = publicUrl.toString();
1291
+ }
1292
+ const socket = new WebSocket(transportUrl);
425
1293
  this.socket = socket;
426
1294
  socket.addEventListener("open", () => {
427
1295
  if (this.socket !== socket)
@@ -461,7 +1329,7 @@ export class GonvexClient {
461
1329
  // drop them too — flushing them after reconnect would fire writes whose
462
1330
  // callers already saw a rejection.
463
1331
  this.pendingMessages.length = 0;
464
- this.pendingReplicaTransactions.length = 0;
1332
+ this.pendingReplicaFrames.length = 0;
465
1333
  // Reducers/actions must fail closed on transport loss: silently
466
1334
  // replaying a non-idempotent write after reconnect is unsafe, and
467
1335
  // leaving the promise pending hangs the caller forever.
@@ -504,6 +1372,10 @@ export class GonvexClient {
504
1372
  }
505
1373
  return;
506
1374
  }
1375
+ if (message.type === "client.updateRequired") {
1376
+ this.requireClientUpdate(message.reason);
1377
+ return;
1378
+ }
507
1379
  if (message.type === "system.reload") {
508
1380
  if (typeof message.artifactHash === "string") {
509
1381
  this.activeArtifactHashValue = message.artifactHash;
@@ -520,7 +1392,7 @@ export class GonvexClient {
520
1392
  // subscription. Rotate their routing IDs immediately so delayed
521
1393
  // Replica errors from that discarded scope cannot reach current
522
1394
  // React listeners.
523
- this.pendingReplicaTransactions.length = 0;
1395
+ this.pendingReplicaFrames.length = 0;
524
1396
  this.quarantineReplicaScope();
525
1397
  this.activeAuthFrameId = undefined;
526
1398
  // If credentials are already being revalidated, that queued auth
@@ -537,7 +1409,7 @@ export class GonvexClient {
537
1409
  this.sendAuth(true, { useFetcher: false });
538
1410
  return;
539
1411
  }
540
- const fetcher = this.auth.fetchToken;
1412
+ const fetcher = this.publicTenant ? undefined : this.auth.fetchToken;
541
1413
  if (fetcher) {
542
1414
  this.authInFlight = true;
543
1415
  this.authRetriedAfterError = true;
@@ -577,6 +1449,8 @@ export class GonvexClient {
577
1449
  this.activeAuthFrameId = message.id;
578
1450
  else
579
1451
  this.activeAuthFrameId = undefined;
1452
+ if (message.type === "auth.result")
1453
+ this.flushPendingNativeErrors();
580
1454
  if (this.authWatchdogTimer) {
581
1455
  clearTimeout(this.authWatchdogTimer);
582
1456
  this.authWatchdogTimer = undefined;
@@ -584,18 +1458,27 @@ export class GonvexClient {
584
1458
  if (message.type === "auth.result") {
585
1459
  const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
586
1460
  this.authenticatedSocketGeneration = this.socketGeneration;
1461
+ this.lastOnlineAtMs = Date.now();
587
1462
  this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
588
1463
  const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
589
1464
  if (developerSessionToken) {
590
- // The activation token is single-use. Keep its rotating successor
591
- // only in process memory and use it for the next reconnect.
1465
+ // The activation token is single-use. Use its rotating successor
1466
+ // for the next reconnect, and hand it to the provider that owns
1467
+ // developer mode so a reload can resume the same grant.
592
1468
  this.auth = { ...this.auth, token: developerSessionToken, fetchToken: undefined };
1469
+ for (const handler of Array.from(this.developerCredentialHandlers))
1470
+ handler(developerSessionToken);
593
1471
  }
594
1472
  this.authRetriedAfterError = false;
595
1473
  const directive = replicaDirectiveFromAuthResult(message.result);
1474
+ const localIdentity = localIdentityFromAuthResult(message.result);
1475
+ if (localIdentity) {
1476
+ this.localIdentity = localIdentity;
1477
+ void this.localExecutor?.ready.catch(() => undefined);
1478
+ }
596
1479
  if (!directive) {
597
1480
  this.authInFlight = false;
598
- if (!this.auth.tenant) {
1481
+ if (!this.auth.tenant || this.publicTenant) {
599
1482
  this.resumeQuerySubscriptions(reauthenticatedSameSocket);
600
1483
  this.settleManagedAuthAttempt(message.id);
601
1484
  }
@@ -612,12 +1495,18 @@ export class GonvexClient {
612
1495
  }
613
1496
  void this.activateReplicaDirective(directive)
614
1497
  .then(() => this.activateOutboxScope())
1498
+ .then(async () => {
1499
+ if (localIdentity && this.localBinding)
1500
+ await this.localStorage?.saveSession?.(this.outboxScope, {
1501
+ directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
1502
+ });
1503
+ })
615
1504
  .then(() => {
616
1505
  // The accepted server identity is not usable until its durable
617
1506
  // Replica partition has been activated locally. Only now may
618
1507
  // tenant calls and subscriptions leave the client.
619
1508
  this.authInFlight = false;
620
- this.drainPendingReplicaTransactions();
1509
+ this.drainPendingReplicaFrames();
621
1510
  // Authentication is replaced in-place on the same socket during
622
1511
  // normal token rotation. The runtime clears its subscription
623
1512
  // maps for every accepted auth frame, so same-generation Live
@@ -632,7 +1521,7 @@ export class GonvexClient {
632
1521
  })
633
1522
  .catch((error) => {
634
1523
  this.authInFlight = false;
635
- this.pendingReplicaTransactions.length = 0;
1524
+ this.pendingReplicaFrames.length = 0;
636
1525
  this.settleManagedAuthAttempt(message.id, error instanceof Error ? error.message : "Runtime returned an invalid Local Replica scope");
637
1526
  this.rejectReplicaDirective(error);
638
1527
  this.flushPendingMessages();
@@ -641,7 +1530,7 @@ export class GonvexClient {
641
1530
  }
642
1531
  else {
643
1532
  this.authInFlight = false;
644
- this.pendingReplicaTransactions.length = 0;
1533
+ this.pendingReplicaFrames.length = 0;
645
1534
  const fetcher = this.auth.fetchToken;
646
1535
  if (fetcher && !this.authRetriedAfterError) {
647
1536
  // The installed token was rejected — typically expired while the
@@ -671,7 +1560,7 @@ export class GonvexClient {
671
1560
  // Replica frames carry no tenant/scope field. During auth renewal we
672
1561
  // cannot safely attribute a late frame to either side of the switch.
673
1562
  if (this.authInFlight) {
674
- this.pendingReplicaTransactions.push(message);
1563
+ this.pendingReplicaFrames.push(message);
675
1564
  return;
676
1565
  }
677
1566
  this.enqueueReplicaTransaction(message);
@@ -683,7 +1572,11 @@ export class GonvexClient {
683
1572
  // stream. Keep both on the same client queue so a watermark cannot
684
1573
  // advance the cursor past a transaction that was received first but
685
1574
  // has not finished applying to durable Local Replica storage yet.
686
- this.enqueueReplicaFrame(() => this.handleReplicaWatermark(message.revision));
1575
+ if (this.authInFlight) {
1576
+ this.pendingReplicaFrames.push(message);
1577
+ return;
1578
+ }
1579
+ this.enqueueReplicaWatermark(message.revision);
687
1580
  }
688
1581
  return;
689
1582
  }
@@ -725,6 +1618,11 @@ export class GonvexClient {
725
1618
  }
726
1619
  close() {
727
1620
  this.manuallyClosed = true;
1621
+ this.unsubscribeLocal?.();
1622
+ for (const close of this.localCollectionClosers.splice(0))
1623
+ close();
1624
+ this.localCollectionKeys.clear();
1625
+ this.localExecutor?.close();
728
1626
  this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
729
1627
  if (isEphemeralOutboxScope(this.outboxScope)) {
730
1628
  void this.reducerOutbox.clear(this.outboxScope);
@@ -760,12 +1658,15 @@ export class GonvexClient {
760
1658
  this.outboxDrainTimer = undefined;
761
1659
  }
762
1660
  this.unsubscribeOutbox();
1661
+ this.unsubscribePeerOutbox?.();
1662
+ this.unsubscribePeerReplica?.();
763
1663
  this.unsubscribeBrowserOnline?.();
764
1664
  this.handlers.clear();
765
1665
  this.querySubscriptions.clear();
766
1666
  this.replicaSubscriptions.clear();
767
1667
  this.sessionScopeHandlers.clear();
768
1668
  this.authErrorHandlers.clear();
1669
+ this.developerCredentialHandlers.clear();
769
1670
  // Invalidate any token fetch still in flight so its resolve can't touch
770
1671
  // the closed client's caches.
771
1672
  this.authFetchGeneration += 1;
@@ -777,6 +1678,10 @@ export class GonvexClient {
777
1678
  this.notifyConnectionState();
778
1679
  this.connectionStateHandlers.clear();
779
1680
  this.supportCommandHandlers.clear();
1681
+ this.replica.dispose();
1682
+ const closedError = new Error("Gonvex client is closed");
1683
+ for (const pending of this.pendingNativeErrors.splice(0))
1684
+ pending.reject(closedError);
780
1685
  if (!socket)
781
1686
  return;
782
1687
  socket.close();
@@ -1110,7 +2015,9 @@ export class GonvexClient {
1110
2015
  // could overwrite a result already accepted on the same connection.
1111
2016
  return subscription.revisionSocketGeneration !== this.socketGeneration;
1112
2017
  }
1113
- subscribeReplicaTransport(ref, args = {}, onMessage) {
2018
+ subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
2019
+ if (metadataOnly)
2020
+ this.replicaMetadataListeners.add(onMessage);
1114
2021
  this.connect();
1115
2022
  const key = querySubscriptionKey(ref, args);
1116
2023
  const existing = this.replicaSubscriptions.get(key);
@@ -1120,10 +2027,14 @@ export class GonvexClient {
1120
2027
  existing.unsubscribeTimer = undefined;
1121
2028
  }
1122
2029
  existing.listeners.add(onMessage);
2030
+ // A reset may arrive during the retention grace period with no listeners.
2031
+ // Its reopen was correctly deferred; the next subscriber must resume it.
2032
+ if (!existing.isUpToDate && !existing.opening)
2033
+ this.sendReplicaOpen(existing);
1123
2034
  if (existing.lastMessage) {
1124
2035
  queueMicrotask(() => {
1125
2036
  if (existing.listeners.has(onMessage) && existing.lastMessage) {
1126
- onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
2037
+ onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
1127
2038
  }
1128
2039
  });
1129
2040
  }
@@ -1161,12 +2072,16 @@ export class GonvexClient {
1161
2072
  return this.subscribeReplicaTransport(ref, args, onMessage);
1162
2073
  }
1163
2074
  /** Watch a bounded Replica Collection through the normalized Local Replica. */
1164
- watchReplica(ref, args = {}) {
2075
+ watchReplica(ref, args = {}, options = {}) {
1165
2076
  const key = querySubscriptionKey(ref, args);
1166
2077
  const updateHandlers = new Set();
1167
2078
  let latestError;
1168
2079
  let snapshotVersion = -1;
1169
2080
  let snapshotRows;
2081
+ const readRows = () => {
2082
+ const next = this.replica.liveQuerySnapshot(key).rows;
2083
+ return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
2084
+ };
1170
2085
  let stateVersion = -1;
1171
2086
  let stateFreshness;
1172
2087
  let stateIsUpToDate;
@@ -1178,45 +2093,57 @@ export class GonvexClient {
1178
2093
  };
1179
2094
  // Keep the ReplicaSubscription as transport/reconciliation state only. The
1180
2095
  // value returned by this watch always comes from normalized LocalReplica.
1181
- const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
1182
- if (message.type === "replica.error") {
1183
- latestError = new Error(message.error);
1184
- notify();
1185
- }
1186
- else if (message.type === "replica.syncing" || message.type === "replica.reset") {
1187
- latestError = undefined;
1188
- notify();
1189
- }
1190
- else if (message.type === "replica.ready") {
1191
- latestError = undefined;
1192
- notify();
1193
- }
1194
- else if (message.type === "replica.snapshot") {
1195
- latestError = undefined;
1196
- }
1197
- });
1198
- const unsubscribeReplica = this.replica.subscribe(notify);
1199
- const unsubscribeScope = this.onSessionScopeChange(() => {
1200
- latestError = undefined;
2096
+ let stop;
2097
+ const start = () => {
2098
+ if (stop)
2099
+ return;
1201
2100
  snapshotVersion = -1;
1202
- snapshotRows = undefined;
1203
2101
  stateVersion = -1;
1204
- stateFreshness = undefined;
1205
- stateIsUpToDate = undefined;
1206
- snapshotState = undefined;
1207
- notify();
1208
- });
2102
+ const releaseRows = this.replica.retainWindow(key);
2103
+ const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
2104
+ if (message.type === "replica.error") {
2105
+ latestError = new Error(message.error);
2106
+ notify();
2107
+ }
2108
+ else if (message.type === "replica.syncing" || message.type === "replica.reset") {
2109
+ latestError = undefined;
2110
+ notify();
2111
+ }
2112
+ else if (message.type === "replica.ready") {
2113
+ latestError = undefined;
2114
+ notify();
2115
+ }
2116
+ else if (message.type === "replica.snapshot") {
2117
+ latestError = undefined;
2118
+ }
2119
+ }, true);
2120
+ const unsubscribeReplica = this.replica.subscribe(notify);
2121
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2122
+ latestError = undefined;
2123
+ snapshotVersion = -1;
2124
+ snapshotRows = undefined;
2125
+ stateVersion = -1;
2126
+ stateFreshness = undefined;
2127
+ stateIsUpToDate = undefined;
2128
+ snapshotState = undefined;
2129
+ notify();
2130
+ });
2131
+ stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2132
+ };
2133
+ // React can discard a render before subscribing. Such watches must own no resources.
2134
+ if (!options.deferStart)
2135
+ start();
1209
2136
  return {
1210
2137
  localReplicaResult: () => {
1211
2138
  if (latestError)
1212
2139
  throw latestError;
1213
2140
  if (!this.replica.hasLiveQuery(key))
1214
2141
  return undefined;
1215
- const version = this.replica.windowVersion(key);
2142
+ const version = this.replica.windowRowsVersion(key);
1216
2143
  if (snapshotVersion === version)
1217
2144
  return snapshotRows;
1218
2145
  snapshotVersion = version;
1219
- snapshotRows = this.replica.liveQuery(key).rows;
2146
+ snapshotRows = readRows();
1220
2147
  return snapshotRows;
1221
2148
  },
1222
2149
  localReplicaState: () => {
@@ -1234,7 +2161,12 @@ export class GonvexClient {
1234
2161
  stateVersion = version;
1235
2162
  stateFreshness = freshness;
1236
2163
  stateIsUpToDate = isUpToDate;
1237
- const state = this.replica.collectionState(key);
2164
+ const rowsVersion = this.replica.windowRowsVersion(key);
2165
+ if (snapshotVersion !== rowsVersion) {
2166
+ snapshotVersion = rowsVersion;
2167
+ snapshotRows = readRows();
2168
+ }
2169
+ const state = this.replica.collectionState(key, snapshotRows);
1238
2170
  snapshotState = {
1239
2171
  ...state,
1240
2172
  isUpToDate,
@@ -1250,6 +2182,7 @@ export class GonvexClient {
1250
2182
  isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
1251
2183
  }),
1252
2184
  onUpdate(handler) {
2185
+ start();
1253
2186
  if (releaseTimer) {
1254
2187
  clearTimeout(releaseTimer);
1255
2188
  releaseTimer = undefined;
@@ -1267,9 +2200,7 @@ export class GonvexClient {
1267
2200
  releaseTimer = undefined;
1268
2201
  if (updateHandlers.size > 0)
1269
2202
  return;
1270
- unsubscribeTransport();
1271
- unsubscribeReplica();
1272
- unsubscribeScope();
2203
+ stop?.();
1273
2204
  }, 0);
1274
2205
  };
1275
2206
  },
@@ -1280,7 +2211,7 @@ export class GonvexClient {
1280
2211
  * latest query result is retained only as the transport-shaped skeleton;
1281
2212
  * its row window is always rebuilt from LocalReplica membership/entities.
1282
2213
  */
1283
- watchLiveQuery(ref, args = {}) {
2214
+ watchLiveQuery(ref, args = {}, options = {}) {
1284
2215
  const key = querySubscriptionKey(ref, args);
1285
2216
  const updateHandlers = new Set();
1286
2217
  let transportResult;
@@ -1300,39 +2231,51 @@ export class GonvexClient {
1300
2231
  handler();
1301
2232
  });
1302
2233
  };
1303
- const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
1304
- if (message.type === "query.result") {
1305
- transportResult = message.result;
2234
+ let stop;
2235
+ const start = () => {
2236
+ if (stop)
2237
+ return;
2238
+ transportResult = undefined;
2239
+ snapshotToken = "";
2240
+ const releaseRows = this.replica.retainWindow(key);
2241
+ const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
2242
+ if (message.type === "query.result") {
2243
+ transportResult = message.result;
2244
+ transportGeneration += 1;
2245
+ latestError = undefined;
2246
+ // LocalReplica has already published its atomic window swap before
2247
+ // this callback is emitted, so this is the single initial UI wake-up.
2248
+ notify();
2249
+ }
2250
+ else if (message.type === "query.error") {
2251
+ latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
2252
+ code: "server", path: ref.path, operation: "query",
2253
+ });
2254
+ notify();
2255
+ }
2256
+ });
2257
+ // During the initial query result, LocalReplica notifies before the
2258
+ // transport-shaped skeleton is installed above. Suppress that empty
2259
+ // intermediate wake-up; later transactions notify directly from the
2260
+ // normalized store.
2261
+ const unsubscribeReplica = this.replica.subscribe(() => {
2262
+ if (transportResult !== undefined || this.replica.hasLiveQuery(key))
2263
+ notify();
2264
+ });
2265
+ void this.replicaReady.then(() => notify());
2266
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2267
+ transportResult = undefined;
1306
2268
  transportGeneration += 1;
2269
+ snapshotToken = "";
2270
+ snapshotResult = undefined;
1307
2271
  latestError = undefined;
1308
- // LocalReplica has already published its atomic window swap before
1309
- // this callback is emitted, so this is the single initial UI wake-up.
1310
- notify();
1311
- }
1312
- else if (message.type === "query.error") {
1313
- latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
1314
- code: "server", path: ref.path, operation: "query",
1315
- });
1316
2272
  notify();
1317
- }
1318
- });
1319
- // During the initial query result, LocalReplica notifies before the
1320
- // transport-shaped skeleton is installed above. Suppress that empty
1321
- // intermediate wake-up; later transactions notify directly from the
1322
- // normalized store.
1323
- const unsubscribeReplica = this.replica.subscribe(() => {
1324
- if (transportResult !== undefined || this.replica.hasLiveQuery(key))
1325
- notify();
1326
- });
1327
- void this.replicaReady.then(() => notify());
1328
- const unsubscribeScope = this.onSessionScopeChange(() => {
1329
- transportResult = undefined;
1330
- transportGeneration += 1;
1331
- snapshotToken = "";
1332
- snapshotResult = undefined;
1333
- latestError = undefined;
1334
- notify();
1335
- });
2273
+ });
2274
+ stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2275
+ };
2276
+ // React can discard a render before subscribing. Such watches must own no resources.
2277
+ if (!options.deferStart)
2278
+ start();
1336
2279
  return {
1337
2280
  localLiveQueryResult: () => {
1338
2281
  if (latestError)
@@ -1367,6 +2310,7 @@ export class GonvexClient {
1367
2310
  return snapshotResult;
1368
2311
  },
1369
2312
  onUpdate(handler) {
2313
+ start();
1370
2314
  if (releaseTimer) {
1371
2315
  clearTimeout(releaseTimer);
1372
2316
  releaseTimer = undefined;
@@ -1384,9 +2328,7 @@ export class GonvexClient {
1384
2328
  releaseTimer = undefined;
1385
2329
  if (updateHandlers.size > 0)
1386
2330
  return;
1387
- unsubscribeQuery();
1388
- unsubscribeReplica();
1389
- unsubscribeScope();
2331
+ stop?.();
1390
2332
  }, 0);
1391
2333
  };
1392
2334
  },
@@ -1431,7 +2373,7 @@ export class GonvexClient {
1431
2373
  scope,
1432
2374
  };
1433
2375
  await this.replica.replaceWindow(window);
1434
- const snapshot = { ...message, result: this.replica.windowRows(subscription.key) };
2376
+ const snapshot = { ...message, result: [] };
1435
2377
  subscription.lastMessage = snapshot;
1436
2378
  this.emitReplicaMessage(subscription, snapshot, scope);
1437
2379
  return;
@@ -1442,12 +2384,20 @@ export class GonvexClient {
1442
2384
  return;
1443
2385
  this.clearReplicaRetry(subscription, true);
1444
2386
  raiseReplicaCursorFloor(subscription, message.cursor);
2387
+ const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
2388
+ // Incremental integrity is safe only for full projected row images. Older
2389
+ // partial-row protocols keep the full normalized rehash on replica.ready.
2390
+ const canCarryHashes = prior?.hashes && subscription.columns?.length
2391
+ && upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
2392
+ const hashes = message.hashes ?? (canCarryHashes
2393
+ ? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
2394
+ : undefined);
1445
2395
  await this.replica.applyWindowDelta({
1446
2396
  signature: subscription.key,
1447
2397
  kind: "replica",
1448
2398
  entity: subscription.entity,
1449
2399
  key: prior?.key ?? "id",
1450
- upserts: (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
2400
+ upserts,
1451
2401
  deleted: message.deleted ?? [],
1452
2402
  completeness: prior?.completeness ?? "partial",
1453
2403
  source: "server",
@@ -1458,13 +2408,11 @@ export class GonvexClient {
1458
2408
  orderDirection: prior?.orderDirection,
1459
2409
  maxRows: prior?.maxRows,
1460
2410
  maxBytes: prior?.maxBytes,
1461
- // A delta invalidates the prior full integrity map unless the server
1462
- // supplied a complete replacement map with this frame.
1463
- hashes: message.hashes,
2411
+ hashes,
1464
2412
  });
1465
2413
  const snapshot = {
1466
2414
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1467
- result: this.replica.windowRows(subscription.key), cursor: message.cursor,
2415
+ result: [], cursor: message.cursor,
1468
2416
  key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
1469
2417
  orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
1470
2418
  };
@@ -1518,12 +2466,34 @@ export class GonvexClient {
1518
2466
  }
1519
2467
  this.emitReplicaMessage(subscription, message, scope);
1520
2468
  }
1521
- enqueueReplicaFrame(operation) {
2469
+ enqueueReplicaFrame(operation, watermark = false) {
2470
+ // Any entity/window frame seals the adjacent watermark batch. Advancing
2471
+ // across that barrier could make an unapplied transaction look stale.
2472
+ if (!watermark)
2473
+ this.queuedReplicaWatermark = undefined;
1522
2474
  const processing = this.replicaFrames.then(operation);
1523
2475
  this.replicaFrames = processing.catch(() => {
1524
2476
  this.replica.setFreshness("verifying");
1525
2477
  });
1526
2478
  }
2479
+ enqueueReplicaWatermark(revision) {
2480
+ if (!Number.isSafeInteger(revision) || revision < 0)
2481
+ return;
2482
+ const pending = this.queuedReplicaWatermark;
2483
+ if (pending && pending.scope === this.replicaScope) {
2484
+ pending.revision = Math.max(pending.revision, revision);
2485
+ return;
2486
+ }
2487
+ const batch = { revision, scope: this.replicaScope };
2488
+ this.queuedReplicaWatermark = batch;
2489
+ this.enqueueReplicaFrame(async () => {
2490
+ if (this.queuedReplicaWatermark === batch)
2491
+ this.queuedReplicaWatermark = undefined;
2492
+ if (batch.scope !== this.replicaScope)
2493
+ return;
2494
+ await this.handleReplicaWatermark(batch.revision);
2495
+ }, true);
2496
+ }
1527
2497
  enqueueReplicaTransaction(message) {
1528
2498
  const scope = this.replicaScope;
1529
2499
  this.enqueueReplicaFrame(async () => {
@@ -1542,10 +2512,14 @@ export class GonvexClient {
1542
2512
  }
1543
2513
  });
1544
2514
  }
1545
- drainPendingReplicaTransactions() {
1546
- const pending = this.pendingReplicaTransactions.splice(0);
1547
- for (const message of pending)
1548
- this.enqueueReplicaTransaction(message);
2515
+ drainPendingReplicaFrames() {
2516
+ const pending = this.pendingReplicaFrames.splice(0);
2517
+ for (const message of pending) {
2518
+ if (message.type === "replica.transaction")
2519
+ this.enqueueReplicaTransaction(message);
2520
+ else
2521
+ this.enqueueReplicaWatermark(message.revision);
2522
+ }
1549
2523
  }
1550
2524
  async acceptReplicaReady(subscription, message, hashes, hashesWereStored, scope = this.replicaScope) {
1551
2525
  this.clearReplicaRetry(subscription, true);
@@ -1590,12 +2564,9 @@ export class GonvexClient {
1590
2564
  return;
1591
2565
  const eligibleSignatures = [];
1592
2566
  for (const subscription of this.replicaSubscriptions.values()) {
1593
- const cursor = this.replica.getWindow(subscription.key)?.cursor;
1594
- if (!cursor
1595
- || cursor.revision >= revision
1596
- || !subscription.isUpToDate
2567
+ if (!subscription.isUpToDate
1597
2568
  || subscription.opening
1598
- || !this.replica.getWindow(subscription.key)?.hashes)
2569
+ || !this.replica.windowCanAdvance(subscription.key, revision))
1599
2570
  continue;
1600
2571
  eligibleSignatures.push(subscription.key);
1601
2572
  }
@@ -1614,9 +2585,15 @@ export class GonvexClient {
1614
2585
  emitReplicaMessage(subscription, message, scope = this.replicaScope) {
1615
2586
  if (scope !== this.replicaScope || subscription.scope !== scope)
1616
2587
  return;
1617
- const outgoing = this.materializeReplicaMessage(subscription, message);
1618
- for (const listener of Array.from(subscription.listeners))
1619
- listener(outgoing);
2588
+ // Hook observers consume metadata only; their rows come from Local Replica.
2589
+ // Materialize a payload only when a public transport consumer needs it.
2590
+ let outgoing;
2591
+ for (const listener of Array.from(subscription.listeners)) {
2592
+ if (this.replicaMetadataListeners.has(listener))
2593
+ listener(message);
2594
+ else
2595
+ listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
2596
+ }
1620
2597
  }
1621
2598
  materializeReplicaMessage(subscription, message) {
1622
2599
  if (message.type !== "replica.snapshot")
@@ -1662,7 +2639,7 @@ export class GonvexClient {
1662
2639
  subscription.isUpToDate = false;
1663
2640
  const message = {
1664
2641
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1665
- result: this.replica.windowRows(subscription.key), cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
2642
+ result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
1666
2643
  key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
1667
2644
  orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
1668
2645
  };
@@ -1771,8 +2748,11 @@ export class GonvexClient {
1771
2748
  this.outboxScope = scope;
1772
2749
  const ready = this.hasAuthoritativeReplicaScope
1773
2750
  ? this.restoreOutbox(scope, generation)
1774
- : Promise.resolve();
1775
- this.outboxReady = ready;
2751
+ : this.restoreLocalSession(scope, generation);
2752
+ this.setOutboxReady(ready);
2753
+ // Never show the previous identity's intents under the new one.
2754
+ this.publishIntents([]);
2755
+ this.refreshIntents();
1776
2756
  return ready;
1777
2757
  }
1778
2758
  async activateReplicaDirective(directive) {
@@ -1795,8 +2775,11 @@ export class GonvexClient {
1795
2775
  this.resetReplicaScopeState();
1796
2776
  this.replicaScope = scope;
1797
2777
  this.hasAuthoritativeReplicaScope = true;
1798
- this.replicaReady = this.replica.activateScope(scope);
2778
+ this.setReplicaReady(this.replica.activateScope(scope));
1799
2779
  this.rotateSubscriptionScopes();
2780
+ // Subscriptions follow scope rotation, so this opens each resident
2781
+ // dependency collection once per client, from cache first when offline.
2782
+ this.ensureLocalCollections(this.residentDependencyTables);
1800
2783
  const generation = this.outboxScopeGeneration;
1801
2784
  // Publish the recovery barrier before yielding to Replica storage. A
1802
2785
  // reducer may be invoked as soon as the auth result arrives, while the
@@ -1804,10 +2787,24 @@ export class GonvexClient {
1804
2787
  // points at the old resolved promise, that reducer can enqueue an inflight
1805
2788
  // row which the concurrent recovery then mistakes for an abandoned call
1806
2789
  // and sends a second time with the same command ID.
1807
- this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
2790
+ this.setOutboxReady(this.restoreOutbox(this.outboxScope, generation));
1808
2791
  await this.outboxReady;
1809
2792
  }
2793
+ // Scope activation rejects once the replica is disposed (for example when
2794
+ // an auth error quarantines a client that was already closed). Callers that
2795
+ // await replicaReady still see the rejection; the stored promise itself must
2796
+ // not surface as an unhandled rejection that kills Node processes.
2797
+ setReplicaReady(ready) {
2798
+ this.replicaReady = ready;
2799
+ ready.catch(() => undefined);
2800
+ }
2801
+ // Outbox restore awaits replicaReady, so it rejects the same way.
2802
+ setOutboxReady(ready) {
2803
+ this.outboxReady = ready;
2804
+ ready.catch(() => undefined);
2805
+ }
1810
2806
  quarantineReplicaScope() {
2807
+ this.localIdentity = undefined;
1811
2808
  // Keep the durable prior identity scope intact for an authorized future
1812
2809
  // login, but make every synchronous selector fail closed immediately.
1813
2810
  // The random suffix prevents a denied scope from ever restoring rows.
@@ -1819,7 +2816,7 @@ export class GonvexClient {
1819
2816
  this.resetReplicaScopeState();
1820
2817
  this.replicaScope = scope;
1821
2818
  this.hasAuthoritativeReplicaScope = false;
1822
- this.replicaReady = this.replica.activateScope(scope, true);
2819
+ this.setReplicaReady(this.replica.activateScope(scope, true));
1823
2820
  this.rotateSubscriptionScopes();
1824
2821
  }
1825
2822
  rejectMissingReplicaDirective() {
@@ -1832,12 +2829,26 @@ export class GonvexClient {
1832
2829
  }
1833
2830
  async restoreOutbox(scope, generation) {
1834
2831
  await this.replicaReady;
2832
+ await this.replica.synchronizeStorage();
1835
2833
  const entries = await this.reducerOutbox.loadAll(scope);
1836
2834
  if (this.manuallyClosed
1837
2835
  || generation !== this.outboxScopeGeneration
1838
2836
  || scope !== this.outboxScope)
1839
2837
  return;
1840
2838
  for (const entry of entries) {
2839
+ // markCommitted follows the durable replica watermark barrier. Its
2840
+ // transaction is already in the restored base; never predict it twice.
2841
+ if (entry.localExecution && entry.state === "committed")
2842
+ await this.reducerOutbox.ack(entry.id);
2843
+ }
2844
+ if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
2845
+ await this.inLocalLane(() => this.rebaseLocalEntries());
2846
+ }
2847
+ for (const entry of entries) {
2848
+ if (entry.localExecution && this.localExecutor)
2849
+ continue;
2850
+ if (!outboxEntryIsLive(entry))
2851
+ continue;
1841
2852
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
1842
2853
  await this.reducerOutbox.ack(entry.id);
1843
2854
  continue;
@@ -1854,11 +2865,15 @@ export class GonvexClient {
1854
2865
  // If this scope was installed after the socket authenticated, no reconnect
1855
2866
  // or new enqueue may occur to wake the queue. The await inside drainOutbox
1856
2867
  // yields until this restore promise resolves, then safely resumes it.
2868
+ this.refreshIntents();
1857
2869
  void this.drainOutbox();
1858
2870
  }
1859
2871
  addOptimisticReducer(reducerId, patches, accepted = false) {
1860
- if (patches.length === 0 || this.optimisticReducerIds.has(reducerId))
2872
+ if (patches.length === 0) {
2873
+ this.optimisticReducerIds.delete(reducerId);
2874
+ this.replica.rejectCommand(reducerId);
1861
2875
  return;
2876
+ }
1862
2877
  this.optimisticReducerIds.add(reducerId);
1863
2878
  this.replica.applyOptimistic(reducerId, patches);
1864
2879
  }
@@ -1878,69 +2893,195 @@ export class GonvexClient {
1878
2893
  await this.reducerOutbox.ack(entryId);
1879
2894
  }
1880
2895
  async drainOutbox() {
1881
- await this.outboxReady;
1882
- if (this.drainingOutbox
1883
- || this.manuallyClosed
1884
- || !this.canSendReducerNow())
2896
+ if (this.publicTenant)
2897
+ return;
2898
+ // A failed restore is reported to whoever awaits the scope transition;
2899
+ // there is nothing to drain from a scope that never became ready.
2900
+ try {
2901
+ await this.outboxReady;
2902
+ }
2903
+ catch {
2904
+ return;
2905
+ }
2906
+ if (this.drainingOutbox) {
2907
+ // The running drain may already be past the entry this wake-up is for
2908
+ // (for example a short backoff timer firing before the drain returned).
2909
+ this.outboxDrainRequested = true;
2910
+ return;
2911
+ }
2912
+ if (this.manuallyClosed || !this.canSendReducerNow())
1885
2913
  return;
1886
2914
  const drainScope = this.outboxScope;
1887
2915
  this.drainingOutbox = true;
2916
+ this.outboxDrainRequested = false;
1888
2917
  try {
1889
- while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
1890
- const scope = this.outboxScope;
1891
- const entry = await this.reducerOutbox.nextReady(scope, Date.now());
1892
- if (!entry)
1893
- return;
1894
- if (scope !== this.outboxScope)
1895
- return;
1896
- if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
1897
- // Scope recovery may observe an inflight row created by this live
1898
- // process and reset it to pending under the assumption that a prior
1899
- // process crashed. The foreground call still owns that command ID.
1900
- // Restore the durable marker and wait for its real result instead of
1901
- // registering a second response handler for the same command.
1902
- await this.reducerOutbox.markInflight(entry.id);
1903
- return;
1904
- }
1905
- if (!this.canSendReducerNow()) {
1906
- await this.reducerOutbox.markPending(entry.id);
1907
- return;
1908
- }
1909
- await this.reducerOutbox.markInflight(entry.id);
1910
- if (scope !== this.outboxScope)
2918
+ await this.coordinateOutbox("delivery", async () => {
2919
+ if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
1911
2920
  return;
1912
- if (!this.canSendReducerNow()) {
1913
- await this.reducerOutbox.markPending(entry.id);
1914
- return;
1915
- }
1916
- try {
1917
- await this.call("reducer", { kind: "reducer", path: entry.path }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
1918
- await this.reducerOutbox.markCommitted(entry.id);
1919
- if ((entry.patches?.length ?? 0) > 0) {
1920
- await this.settleOptimisticReducer(entry.idempotencyKey);
2921
+ if (this.sharedOutboxStore)
2922
+ await this.reducerOutbox.recoverInflight?.(drainScope);
2923
+ while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
2924
+ const scope = this.outboxScope;
2925
+ const entry = await this.inLocalLane(async () => {
2926
+ const next = await this.reducerOutbox.nextReady(scope, Date.now());
2927
+ // A concurrent discard (this or another tab) fences the entry; a
2928
+ // refused transition means it must not be sent.
2929
+ if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
2930
+ return null;
2931
+ return next;
2932
+ });
2933
+ if (entry === null)
2934
+ continue;
2935
+ if (!entry) {
2936
+ await this.scheduleNextOutboxAttempt(scope);
2937
+ return;
1921
2938
  }
1922
- else {
1923
- await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2939
+ if (scope !== this.outboxScope)
2940
+ return;
2941
+ if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
2942
+ // Scope recovery may observe an inflight row created by this live
2943
+ // process and reset it to pending under the assumption that a prior
2944
+ // process crashed. The foreground call still owns that command ID.
2945
+ // Restore the durable marker and wait for its real result instead of
2946
+ // registering a second response handler for the same command.
2947
+ await this.reducerOutbox.markInflight(entry.id);
2948
+ return;
1924
2949
  }
1925
- }
1926
- catch (error) {
1927
- if (error instanceof GonvexClientError && error.code === "server") {
1928
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2950
+ if (!this.canSendReducerNow()) {
2951
+ await this.reducerOutbox.markPending(entry.id);
2952
+ return;
2953
+ }
2954
+ if ((await this.reducerOutbox.markInflight(entry.id)) === false)
1929
2955
  continue;
2956
+ if (scope !== this.outboxScope)
2957
+ return;
2958
+ if (!this.canSendReducerNow()) {
2959
+ await this.reducerOutbox.markPending(entry.id);
2960
+ return;
2961
+ }
2962
+ try {
2963
+ await this.call("reducer", { kind: "reducer", path: entry.path, receiptPath: entry.receiptPath, ...(entry.localExecution ? { artifactHash: entry.localExecution.artifactHash, intentEntropy: entry.localExecution.intentEntropy } : {}) }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
2964
+ this.unauthenticatedRetries = 0;
2965
+ await this.reducerOutbox.markCommitted(entry.id);
2966
+ if (entry.localExecution && this.localExecutor) {
2967
+ await this.inLocalLane(async () => {
2968
+ await this.reducerOutbox.ack(entry.id);
2969
+ if (scope === this.outboxScope)
2970
+ await this.rebaseLocalEntries();
2971
+ });
2972
+ }
2973
+ else if ((entry.patches?.length ?? 0) > 0) {
2974
+ await this.settleOptimisticReducer(entry.idempotencyKey);
2975
+ }
2976
+ else {
2977
+ await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2978
+ }
2979
+ }
2980
+ catch (error) {
2981
+ const disposition = deliveryErrorClass(error);
2982
+ if (disposition === "rejected") {
2983
+ // Keep today's rollback and rebase, but record the rejection
2984
+ // durably instead of deleting the intent without a trace.
2985
+ if (entry.localExecution && this.localExecutor) {
2986
+ await this.inLocalLane(async () => {
2987
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2988
+ if (scope === this.outboxScope) {
2989
+ await this.rebaseLocalEntries(true);
2990
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2991
+ }
2992
+ });
2993
+ }
2994
+ else {
2995
+ this.optimisticReducerIds.delete(entry.idempotencyKey);
2996
+ this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
2997
+ this.replica.rejectCommand(entry.idempotencyKey);
2998
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2999
+ if (scope === this.outboxScope)
3000
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
3001
+ }
3002
+ continue;
3003
+ }
3004
+ const outcome = await this.recordDeliveryFailure(entry, error, disposition);
3005
+ // A parked intent keeps its prediction but no longer blocks the
3006
+ // chain; later intents proceed and the server validates them.
3007
+ if (outcome === "parked")
3008
+ continue;
3009
+ return;
1930
3010
  }
1931
- await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
1932
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
1933
- return;
1934
3011
  }
1935
- }
3012
+ });
1936
3013
  }
1937
3014
  finally {
1938
3015
  this.drainingOutbox = false;
1939
- if (!this.manuallyClosed && drainScope !== this.outboxScope) {
3016
+ this.scheduleLocalReplay();
3017
+ const requested = this.outboxDrainRequested;
3018
+ this.outboxDrainRequested = false;
3019
+ if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
1940
3020
  void this.drainOutbox();
1941
3021
  }
1942
3022
  }
1943
3023
  }
3024
+ /**
3025
+ * Record a non-rejection delivery failure and arm the next attempt.
3026
+ * - update_required: keep the intent pending and stop for an app update.
3027
+ * - unauthenticated: keep the intent, re-authenticate, retry with backoff
3028
+ * that never spends the retry budget.
3029
+ * - network: connectivity loss; retried on reconnect without spending budget.
3030
+ * - transient (and timeouts): exponential backoff, parked as `failed` once
3031
+ * the retry budget is spent.
3032
+ */
3033
+ async recordDeliveryFailure(entry, error, disposition) {
3034
+ const message = reducerErrorMessage(error);
3035
+ if (disposition === "update_required") {
3036
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
3037
+ this.requireClientUpdate(message);
3038
+ return "update_required";
3039
+ }
3040
+ if (disposition === "unauthenticated") {
3041
+ const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
3042
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
3043
+ this.requestReauthentication();
3044
+ this.scheduleOutboxDrain(delay);
3045
+ return "retrying";
3046
+ }
3047
+ const counted = disposition === "transient";
3048
+ const updated = await this.reducerOutbox.fail(entry.id, message, {
3049
+ errorClass: disposition,
3050
+ countAttempt: counted,
3051
+ maxAttempts: this.outboxRetry.maxAttempts,
3052
+ maxBackoffMs: this.outboxRetry.maxBackoffMs,
3053
+ ...(counted ? {} : { delayMs: 1_000 }),
3054
+ });
3055
+ if (updated && updated.state === "failed")
3056
+ return "parked";
3057
+ const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
3058
+ this.scheduleOutboxDrain(delay);
3059
+ return "retrying";
3060
+ }
3061
+ /**
3062
+ * Arm a timer for the earliest backed-off entry. Timer clocks and
3063
+ * `Date.now()` can disagree by a millisecond, so a wake-up may find its
3064
+ * entry not quite due; without this the queue would wait for an unrelated
3065
+ * event to resume.
3066
+ */
3067
+ async scheduleNextOutboxAttempt(scope) {
3068
+ const now = Date.now();
3069
+ let next = Infinity;
3070
+ for (const entry of await this.reducerOutbox.list(scope)) {
3071
+ if (entry.state === "pending" && entry.nextAttemptAt > now)
3072
+ next = Math.min(next, entry.nextAttemptAt);
3073
+ }
3074
+ if (Number.isFinite(next) && scope === this.outboxScope)
3075
+ this.scheduleOutboxDrain(next - now + 1);
3076
+ }
3077
+ /** Re-send auth on the open socket so a lost tenant session is restored. */
3078
+ requestReauthentication() {
3079
+ if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
3080
+ return;
3081
+ if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
3082
+ return;
3083
+ this.sendAuth(false);
3084
+ }
1944
3085
  scheduleOutboxDrain(delay) {
1945
3086
  if (this.manuallyClosed)
1946
3087
  return;
@@ -1952,6 +3093,13 @@ export class GonvexClient {
1952
3093
  }, delay);
1953
3094
  }
1954
3095
  reducer(ref, args = {}, options = {}) {
3096
+ if (this.publicTenant) {
3097
+ if (options.offline === "queue" || options.optimistic?.length)
3098
+ return Promise.reject(new GonvexClientError("Public reducers are server-only and cannot queue or execute optimistically", { code: "disconnected", path: ref.path, operation: "reducer" }));
3099
+ return this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs);
3100
+ }
3101
+ if (ref.localExecution === 1)
3102
+ return this.runLocalReducer(ref, args);
1955
3103
  if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
1956
3104
  return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
1957
3105
  }
@@ -1978,7 +3126,6 @@ export class GonvexClient {
1978
3126
  }
1979
3127
  this.directOutboxReducerIds.add(reducerId);
1980
3128
  let entryId;
1981
- let entryAttempts = 0;
1982
3129
  try {
1983
3130
  const scope = this.outboxScope;
1984
3131
  const entry = await this.reducerOutbox.enqueue({
@@ -1991,7 +3138,6 @@ export class GonvexClient {
1991
3138
  state: "inflight",
1992
3139
  });
1993
3140
  entryId = entry.id;
1994
- entryAttempts = entry.attempts;
1995
3141
  if (this.manuallyClosed) {
1996
3142
  await this.reducerOutbox.ack(entry.id);
1997
3143
  throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
@@ -2019,14 +3165,13 @@ export class GonvexClient {
2019
3165
  return result;
2020
3166
  }
2021
3167
  catch (error) {
2022
- if (isQueueableReducerError(error) && options.offline === "queue") {
3168
+ const disposition = deliveryErrorClass(error);
3169
+ if (disposition !== "rejected" && options.offline === "queue") {
2023
3170
  const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
2024
3171
  if (queuedEntryId !== undefined) {
2025
- await this.reducerOutbox.fail(queuedEntryId, reducerErrorMessage(error));
2026
- // `fail` deliberately records backoff, but it does not own the
2027
- // client's timer. The foreground queueable path must schedule the
2028
- // next deterministic drain just like the background drain path.
2029
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
3172
+ // Records backoff (or parks the entry) and arms the client's timer,
3173
+ // exactly like the background drain path.
3174
+ await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
2030
3175
  }
2031
3176
  return { status: "queued", reducerId };
2032
3177
  }
@@ -2038,7 +3183,17 @@ export class GonvexClient {
2038
3183
  void this.drainOutbox();
2039
3184
  }
2040
3185
  }
3186
+ requireClientUpdate(reason) {
3187
+ if (this.updateRequired)
3188
+ return;
3189
+ this.updateRequired = true;
3190
+ // Keep durable intents intact. Closing suspends subscriptions and future sends.
3191
+ this.close();
3192
+ this.updateRequiredHandler?.(reason);
3193
+ }
2041
3194
  canSendReducerNow() {
3195
+ if (this.updateRequired)
3196
+ return false;
2042
3197
  if (globalThis.navigator?.onLine === false)
2043
3198
  return false;
2044
3199
  const socket = this.socket;
@@ -2183,6 +3338,10 @@ export class GonvexClient {
2183
3338
  ...(ref.scope === "control" ? { scope: "control" } : {}),
2184
3339
  trace: { clientSentAtMs: entry.clientSentAtMs },
2185
3340
  ...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
3341
+ ...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
3342
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
3343
+ ...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
3344
+ ...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
2186
3345
  });
2187
3346
  }
2188
3347
  else {
@@ -2233,8 +3392,11 @@ export class GonvexClient {
2233
3392
  return;
2234
3393
  }
2235
3394
  if (kind === "reducer" && message.type === "reducer.result") {
2236
- this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2237
3395
  const complete = () => {
3396
+ // A collection frame can advance the shared cursor before another
3397
+ // collection's rows and membership have arrived. Retire the local
3398
+ // prediction only after the commit's durable watermark barrier.
3399
+ this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2238
3400
  settle();
2239
3401
  this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
2240
3402
  resolve(message.result);
@@ -2258,7 +3420,11 @@ export class GonvexClient {
2258
3420
  if (kind === "reducer" && message.type === "reducer.error") {
2259
3421
  settle();
2260
3422
  this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
2261
- reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
3423
+ const errorClass = reducerErrorClassFromMessage(message);
3424
+ reject(new GonvexClientError(message.error, {
3425
+ code: "server", path: ref.path, operation: kind,
3426
+ ...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
3427
+ }));
2262
3428
  }
2263
3429
  if (kind === "action" && message.type === "action.result") {
2264
3430
  const complete = () => {
@@ -2485,6 +3651,9 @@ export class GonvexClient {
2485
3651
  this.send(message);
2486
3652
  }
2487
3653
  hasControlPlaneWork() {
3654
+ for (const query of this.querySubscriptions.values())
3655
+ if (query.executionScope === "control")
3656
+ return true;
2488
3657
  for (const query of this.oneShotQueries.values())
2489
3658
  if (query.scope === "control")
2490
3659
  return true;
@@ -2615,6 +3784,21 @@ export class GonvexClient {
2615
3784
  if (this.manuallyClosed)
2616
3785
  return Promise.reject(new Error("Gonvex client is closed"));
2617
3786
  this.connect();
3787
+ if (!this.activeAuthFrameId) {
3788
+ return new Promise((resolve, reject) => {
3789
+ this.pendingNativeErrors.push({ type, payload, resolve, reject });
3790
+ });
3791
+ }
3792
+ return this.sendNativeErrorFrame(type, payload);
3793
+ }
3794
+ flushPendingNativeErrors() {
3795
+ if (!this.activeAuthFrameId || this.pendingNativeErrors.length === 0)
3796
+ return;
3797
+ for (const pending of this.pendingNativeErrors.splice(0)) {
3798
+ void this.sendNativeErrorFrame(pending.type, pending.payload).then(pending.resolve, pending.reject);
3799
+ }
3800
+ }
3801
+ sendNativeErrorFrame(type, payload) {
2618
3802
  const id = randomID();
2619
3803
  return new Promise((resolve, reject) => {
2620
3804
  const timer = setTimeout(() => {
@@ -2663,11 +3847,13 @@ export class GonvexClient {
2663
3847
  this.managedAuthAttempt?.ids.add(id);
2664
3848
  this.sendNow({
2665
3849
  type: "auth",
3850
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
2666
3851
  id,
2667
3852
  token: this.auth.token,
2668
3853
  project: this.auth.project,
2669
3854
  tenant: this.auth.tenant,
2670
3855
  controlOnly: !this.auth.tenant,
3856
+ ...(this.publicTenant ? { public: true } : {}),
2671
3857
  device: browserTelemetryInfo(),
2672
3858
  capabilities: { replicaReadyMany: 1, replicaWatermark: 1, queryPagePatch: 1, queryObjectPatch: 1, queryOrderDelta: 1, queryFanout: 1, queryResultBatch: 1 },
2673
3859
  });
@@ -2805,7 +3991,19 @@ export class GonvexClient {
2805
3991
  socket.send(JSON.stringify(message));
2806
3992
  return;
2807
3993
  }
2808
- this.send(message);
3994
+ // A replaced socket re-sends its own work; don't route this message
3995
+ // onto the newer connection a second time.
3996
+ if (this.socket !== socket)
3997
+ return;
3998
+ // Public control calls (such as the token exchange) renew the
3999
+ // session that the pending auth is waiting for. Queueing them behind
4000
+ // that auth deadlocks the connection, so keep their auth bypass.
4001
+ const id = "id" in message ? message.id : undefined;
4002
+ const pending = id ? this.pendingCalls.get(id) ?? this.oneShotQueries.get(id) : undefined;
4003
+ if ("scope" in message && message.scope === "control" && pending?.authorization === "public")
4004
+ this.sendNow(message);
4005
+ else
4006
+ this.send(message);
2809
4007
  }, { once: true });
2810
4008
  return;
2811
4009
  }
@@ -2860,15 +4058,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
2860
4058
  const current = result[head];
2861
4059
  return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
2862
4060
  }
4061
+ // Generated references and their plans are immutable module metadata. Retain
4062
+ // one canonical contract per reference, not a fresh sorted serialization for
4063
+ // every React watch/read. Weak keys do not retain replaced modules or callers.
4064
+ const subscriptionContracts = new WeakMap();
2863
4065
  function querySubscriptionKey(ref, args) {
2864
- const contract = {
2865
- scope: ref.scope ?? "tenant",
2866
- delivery: ref.delivery ?? "oneShot",
2867
- live: ref.live
2868
- ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
2869
- : null,
2870
- };
2871
- return `${ref.path}\u0000${stableStringify(args)}\u0000${stableStringify(contract)}`;
4066
+ let cached = subscriptionContracts.get(ref);
4067
+ if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
4068
+ const contract = {
4069
+ scope: ref.scope ?? "tenant",
4070
+ delivery: ref.delivery ?? "oneShot",
4071
+ live: ref.live
4072
+ ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
4073
+ : null,
4074
+ };
4075
+ cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
4076
+ subscriptionContracts.set(ref, cached);
4077
+ }
4078
+ const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
4079
+ const previous = cached.keys.get(argumentsKey);
4080
+ if (previous !== undefined)
4081
+ return previous;
4082
+ const key = `${argumentsKey}\u0000${cached.value}`;
4083
+ // Search text and pagination may produce unbounded argument combinations.
4084
+ if (cached.keys.size >= 16)
4085
+ cached.keys.delete(cached.keys.keys().next().value);
4086
+ cached.keys.set(argumentsKey, key);
4087
+ return key;
2872
4088
  }
2873
4089
  function countPendingCalls(calls, kind) {
2874
4090
  let count = 0;
@@ -2878,9 +4094,96 @@ function countPendingCalls(calls, kind) {
2878
4094
  }
2879
4095
  return count;
2880
4096
  }
2881
- function isQueueableReducerError(error) {
2882
- return error instanceof GonvexClientError
2883
- && (error.code === "disconnected" || error.code === "timeout");
4097
+ /**
4098
+ * How the outbox treats one failed delivery. Runtimes before
4099
+ * 0.5.2-staging.15 send no class: their server errors stay rejections, as
4100
+ * before, except the two cases the legacy runtime could only express in prose.
4101
+ */
4102
+ function deliveryErrorClass(error) {
4103
+ if (!(error instanceof GonvexClientError))
4104
+ return "transient";
4105
+ switch (error.code) {
4106
+ case "server": return error.errorClass ?? "rejected";
4107
+ case "timeout": return "transient";
4108
+ default: return "network";
4109
+ }
4110
+ }
4111
+ const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
4112
+ function reducerErrorClassFromMessage(message) {
4113
+ if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
4114
+ return message.class;
4115
+ }
4116
+ // Legacy runtimes: the only structured signals were these literal messages.
4117
+ if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
4118
+ return "update_required";
4119
+ if (message.error === legacyUnauthenticatedReducerError)
4120
+ return "unauthenticated";
4121
+ return undefined;
4122
+ }
4123
+ function positiveOr(value, fallback) {
4124
+ return typeof value === "number" && value > 0 ? value : fallback;
4125
+ }
4126
+ function summarizeArgs(args) {
4127
+ let text;
4128
+ try {
4129
+ text = JSON.stringify(args) ?? "";
4130
+ }
4131
+ catch {
4132
+ text = String(args);
4133
+ }
4134
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
4135
+ }
4136
+ function outboxIntentFromEntry(entry) {
4137
+ const entities = new Map();
4138
+ for (const patch of entry.patches ?? []) {
4139
+ const entity = patch.entity ?? patch.collection;
4140
+ if (!entity)
4141
+ continue;
4142
+ entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
4143
+ }
4144
+ return {
4145
+ id: entry.idempotencyKey,
4146
+ entryId: entry.id,
4147
+ reducer: entry.path,
4148
+ state: entry.state,
4149
+ attempts: entry.attempts,
4150
+ ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
4151
+ ...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
4152
+ createdAt: entry.createdAt,
4153
+ nextAttemptAt: entry.nextAttemptAt,
4154
+ ...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
4155
+ args: entry.args,
4156
+ argsSummary: summarizeArgs(entry.args),
4157
+ entities: [...entities.values()],
4158
+ };
4159
+ }
4160
+ function sameIntents(left, right) {
4161
+ if (left.length !== right.length)
4162
+ return false;
4163
+ for (let index = 0; index < left.length; index += 1) {
4164
+ const a = left[index], b = right[index];
4165
+ if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
4166
+ || a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
4167
+ || a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
4168
+ || a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
4169
+ return false;
4170
+ }
4171
+ return true;
4172
+ }
4173
+ /** Row status from a list of intents: failed > rejected > syncing. */
4174
+ export function entityStatusFromIntents(intents, entity, id) {
4175
+ let status;
4176
+ for (const intent of intents) {
4177
+ if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
4178
+ continue;
4179
+ if (intent.state === "failed")
4180
+ return "failed";
4181
+ if (intent.state === "rejected")
4182
+ status = "rejected";
4183
+ else if (intent.state !== "committed" && status === undefined)
4184
+ status = "syncing";
4185
+ }
4186
+ return status;
2884
4187
  }
2885
4188
  function reducerErrorMessage(error) {
2886
4189
  return error instanceof Error ? error.message : String(error);
@@ -2902,6 +4205,9 @@ function stableStringify(value) {
2902
4205
  .join(",")}}`;
2903
4206
  }
2904
4207
  function utf8KeyCompare(left, right) {
4208
+ // ASCII keys have identical UTF-8 and JavaScript lexical ordering.
4209
+ if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
4210
+ return left < right ? -1 : left > right ? 1 : 0;
2905
4211
  const leftBytes = new TextEncoder().encode(left);
2906
4212
  const rightBytes = new TextEncoder().encode(right);
2907
4213
  const length = Math.min(leftBytes.length, rightBytes.length);
@@ -2922,9 +4228,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
2922
4228
  const key = replicaRowKeyValue(row, keyField);
2923
4229
  if (!key || seen.has(key))
2924
4230
  continue;
2925
- const size = replicaJSONSize(row);
2926
4231
  if (maxRows && kept.length >= maxRows)
2927
4232
  break;
4233
+ const size = maxBytes ? replicaJSONSize(row) : 0;
2928
4234
  if (maxBytes && bytes + size > maxBytes)
2929
4235
  break;
2930
4236
  kept.push(row);
@@ -2971,7 +4277,10 @@ function replicaRowKeyValue(value, keyField) {
2971
4277
  return key === null || key === undefined ? "" : String(key);
2972
4278
  }
2973
4279
  function replicaJSONSize(value) {
2974
- return new TextEncoder().encode(stableStringify(value)).byteLength;
4280
+ // Property ordering cannot change the encoded length. Native serialization
4281
+ // avoids recursively allocating sorted key arrays and intermediate strings.
4282
+ const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
4283
+ return new TextEncoder().encode(json).byteLength;
2975
4284
  }
2976
4285
  function applyKeyedPatch(previous, patch) {
2977
4286
  const rows = new Map();
@@ -3038,6 +4347,31 @@ function queryPatchRowKey(value) {
3038
4347
  const candidate = value._id ?? value.id;
3039
4348
  return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
3040
4349
  }
4350
+ function applyLocalPatches(snapshot, patches) {
4351
+ for (const patch of patches) {
4352
+ const entity = patch.entity ?? patch.collection;
4353
+ if (!entity)
4354
+ continue;
4355
+ const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
4356
+ const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
4357
+ if (patch.op === "delete") {
4358
+ if (index >= 0)
4359
+ table.rows.splice(index, 1);
4360
+ }
4361
+ else if (index >= 0)
4362
+ table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
4363
+ else if (patch.op !== "patch")
4364
+ table.rows.push(structuredClone(patch.fields));
4365
+ }
4366
+ }
4367
+ function localIdentityFromAuthResult(result) {
4368
+ if (!result || typeof result !== "object" || Array.isArray(result))
4369
+ return;
4370
+ const identity = result.localIdentity;
4371
+ if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
4372
+ return;
4373
+ return structuredClone(identity);
4374
+ }
3041
4375
  function authFromOptions(options) {
3042
4376
  return {
3043
4377
  project: options.project,
@@ -3056,8 +4390,6 @@ function normalizeQuerySubscriptionRetentionMs(value) {
3056
4390
  return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
3057
4391
  }
3058
4392
  function authIdentityKey(auth) {
3059
- if (!auth.tenant)
3060
- return "";
3061
4393
  if (auth.token) {
3062
4394
  const tokenIdentity = authIdentityKeyFromToken(auth);
3063
4395
  if (tokenIdentity)
@@ -3067,12 +4399,12 @@ function authIdentityKey(auth) {
3067
4399
  // token would supply, so both paths derive the same key for the same Account.
3068
4400
  const hint = auth.identity;
3069
4401
  if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
3070
- return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
4402
+ return [auth.project ?? "", auth.tenant ?? "", hint.iss ?? "", hint.sub].join("\u0000");
3071
4403
  }
3072
4404
  return "";
3073
4405
  }
3074
4406
  function authIdentityKeyFromToken(auth) {
3075
- if (!auth.token || !auth.tenant)
4407
+ if (!auth.token)
3076
4408
  return "";
3077
4409
  const parts = auth.token.split(".");
3078
4410
  if (parts.length < 2)
@@ -3085,7 +4417,7 @@ function authIdentityKeyFromToken(auth) {
3085
4417
  return "";
3086
4418
  return [
3087
4419
  auth.project ?? "",
3088
- auth.tenant,
4420
+ auth.tenant ?? "",
3089
4421
  typeof payload.iss === "string" ? payload.iss : "",
3090
4422
  payload.sub,
3091
4423
  ].join("\u0000");
@@ -3212,4 +4544,56 @@ function detectDeviceType(userAgent) {
3212
4544
  return "mobile";
3213
4545
  return "desktop";
3214
4546
  }
4547
+ /**
4548
+ * Dependency tables of locally executing Reducers that stay resident. A
4549
+ * table qualifies when its unfiltered generated collection declares a small
4550
+ * enough `maxRows` (and `maxBytes`, when bounded). Collections without a
4551
+ * declared bound are never resident: their size is unknown.
4552
+ */
4553
+ export function residentLocalDependencyTables(binding, policy = "bounded") {
4554
+ if (!binding?.localDependencies || policy === "off")
4555
+ return [];
4556
+ const bounds = policy === "bounded" ? {} : policy;
4557
+ const maxRows = bounds.maxRows ?? DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS;
4558
+ const maxBytes = bounds.maxBytes;
4559
+ const tables = new Set(Object.values(binding.localDependencies).flat());
4560
+ return [...tables].filter((table) => (binding.collections ?? []).some((reference) => {
4561
+ const replica = reference.replica;
4562
+ if (replica?.table !== table || Object.keys(replica.equalFilters ?? {}).length || replica.excludeWhenSet?.length)
4563
+ return false;
4564
+ if (!replica.maxRows || replica.maxRows > maxRows)
4565
+ return false;
4566
+ return maxBytes === undefined || (!!replica.maxBytes && replica.maxBytes <= maxBytes);
4567
+ })).sort();
4568
+ }
4569
+ /** Sign-in and account-only pages do not need reducer code loaded. */
4570
+ function deferredLocalExecutor(create) {
4571
+ let executor;
4572
+ let closed = false;
4573
+ const start = () => {
4574
+ if (closed)
4575
+ throw new Error("Local reducer executor is closed");
4576
+ return executor ??= create();
4577
+ };
4578
+ return {
4579
+ get ready() {
4580
+ try {
4581
+ return start().ready;
4582
+ }
4583
+ catch (error) {
4584
+ return Promise.reject(error);
4585
+ }
4586
+ },
4587
+ prepare: async (path) => start().prepare?.(path),
4588
+ execute: async (...args) => start().execute(...args),
4589
+ executeRead: async (...args) => {
4590
+ const instance = start();
4591
+ if (!instance.executeRead)
4592
+ throw new Error('Local executor does not support indexed reads');
4593
+ return instance.executeRead(...args);
4594
+ },
4595
+ replay: async (...args) => start().replay(...args),
4596
+ close: () => { closed = true; executor?.close(); },
4597
+ };
4598
+ }
3215
4599
  //# sourceMappingURL=index.js.map