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

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 +344 -7
  12. package/dist/index.js +1528 -173
  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 +402 -101
  19. package/dist/indexeddb-replica.js.map +1 -1
  20. package/dist/local-replica.d.ts +125 -4
  21. package/dist/local-replica.js +831 -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;
@@ -167,8 +216,15 @@ export class GonvexClient {
167
216
  processedReplicaWatermarkRevision = 0;
168
217
  pendingReplicaTransactions = [];
169
218
  unsubscribeOutbox;
219
+ sharedOutboxStore;
220
+ unsubscribePeerOutbox;
221
+ unsubscribePeerReplica;
222
+ peerRefreshScheduled = false;
223
+ peerRefreshDirty = false;
170
224
  unsubscribeBrowserOnline;
171
225
  drainingOutbox = false;
226
+ /** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
227
+ outboxDrainRequested = false;
172
228
  outboxDrainTimer;
173
229
  sessionScopeHandlers = new Set();
174
230
  errorReporter;
@@ -180,20 +236,68 @@ export class GonvexClient {
180
236
  pendingCalls = new Map();
181
237
  connectionStateHandlers = new Set();
182
238
  supportCommandHandlers = new Set();
239
+ pendingNativeErrors = [];
183
240
  isWebSocketConnected = false;
184
241
  hasEverConnected = false;
185
242
  connectionCount = 0;
186
243
  timeouts;
244
+ clientContract;
245
+ updateRequiredHandler;
246
+ updateRequired = false;
247
+ lastOnlineAtMs = 0;
187
248
  constructor(url, options = {}) {
188
249
  this.url = url;
250
+ if (options.public) {
251
+ if (!options.public.tenant.trim() || !options.project?.trim())
252
+ throw new Error("Public connections require project and tenant");
253
+ if (options.token || options.fetchToken || options.identity || options.tenant)
254
+ throw new Error("Public connections cannot include session credentials or a separate tenant");
255
+ this.publicTenant = options.public.tenant.trim();
256
+ options = { ...options, clientContract: options.clientContract ?? options.localRuntime?.clientContract, tenant: this.publicTenant, localRuntime: undefined, localReplica: undefined, outbox: { enabled: false }, telemetry: false };
257
+ }
258
+ this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
259
+ this.updateRequiredHandler = options.onUpdateRequired;
260
+ this.localBinding = options.localRuntime;
261
+ for (const table of this.localBinding?.tables ?? []) {
262
+ const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
263
+ && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
264
+ this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
265
+ columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
266
+ }
267
+ this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
268
+ this.residentDependencyTables = residentLocalDependencyTables(options.localRuntime, options.localDependencyResidency);
269
+ if (options.onLocalExecutionFallback)
270
+ this.localFallbackHandlers.add(options.onLocalExecutionFallback);
271
+ this.localStorage = options.localReplica?.storage;
189
272
  this.auth = authFromOptions(options);
190
273
  this.telemetryEnabled = options.telemetry === true;
191
274
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
192
275
  this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
276
+ this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
193
277
  this.reducerOutbox = createReducerOutbox(options.outbox);
194
- this.replica = new LocalReplica(options.localReplica?.storage);
278
+ this.outboxRetry = {
279
+ maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
280
+ maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
281
+ };
282
+ this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
195
283
  this.replicaView = createLocalReplicaView(this.replica);
284
+ if (this.localExecutor) {
285
+ let executionVersion = this.replica.executionVersion();
286
+ this.unsubscribeLocal = this.replica.subscribe(() => {
287
+ const next = this.replica.executionVersion();
288
+ if (next === executionVersion)
289
+ return;
290
+ executionVersion = next;
291
+ this.scheduleLocalReplay();
292
+ });
293
+ }
294
+ this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
295
+ this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
296
+ if (scope === this.replicaScope)
297
+ this.refreshPeerOutbox();
298
+ });
196
299
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
300
+ this.refreshIntents();
197
301
  void this.drainOutbox();
198
302
  });
199
303
  if (typeof globalThis.addEventListener === "function") {
@@ -214,7 +318,7 @@ export class GonvexClient {
214
318
  this.outboxScope = initialScope;
215
319
  this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
216
320
  this.outboxScopeGeneration += 1;
217
- this.replicaReady = this.replica.activateScope(this.replicaScope, true);
321
+ this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
218
322
  this.outboxReady = Promise.resolve();
219
323
  this.timeouts = {
220
324
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
@@ -229,6 +333,729 @@ export class GonvexClient {
229
333
  transport: (type, payload) => this.sendNativeError(type, payload),
230
334
  });
231
335
  }
336
+ if (this.localBinding)
337
+ this.setOutboxReady(this.restoreLocalSession(initialScope, this.outboxScopeGeneration));
338
+ }
339
+ /**
340
+ * Authoritative sync failures arrive after an offline call returned locally.
341
+ * The intent also stays in the outbox as `rejected` until it is discarded
342
+ * or retried, so a UI that mounts later can still show it.
343
+ */
344
+ onReducerRejection(listener) {
345
+ this.reducerRejectionHandlers.add(listener);
346
+ return () => this.reducerRejectionHandlers.delete(listener);
347
+ }
348
+ /**
349
+ * Observe every point where a locally executing Reducer degrades to a
350
+ * queued-only intent (no local result). The intent is still durable and is
351
+ * delivered to the server; this exists so apps can log and fix the missing
352
+ * local data. Returns an unsubscribe function.
353
+ */
354
+ onLocalExecutionFallback(listener) {
355
+ this.localFallbackHandlers.add(listener);
356
+ return () => { this.localFallbackHandlers.delete(listener); };
357
+ }
358
+ reportLocalFallback(path, intentId, phase, error) {
359
+ if (!this.localFallbackHandlers.size)
360
+ return;
361
+ const incomplete = error instanceof Error && error.name === "IncompleteReplicaError";
362
+ const table = incomplete
363
+ ? error.read?.table ?? /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1]
364
+ : undefined;
365
+ const event = {
366
+ path, intentId, phase, reason: incomplete ? "incompleteReplica" : "localError",
367
+ ...(table ? { table } : {}),
368
+ error: error instanceof Error ? error.message : String(error),
369
+ };
370
+ for (const listener of this.localFallbackHandlers) {
371
+ try {
372
+ listener(event);
373
+ }
374
+ catch { /* A listener must not break delivery. */ }
375
+ }
376
+ }
377
+ /** Every durable intent of the current identity, oldest first. */
378
+ async listIntents() {
379
+ await this.outboxReady;
380
+ return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
381
+ }
382
+ /**
383
+ * Synchronous snapshot for external stores (React `useSyncExternalStore`).
384
+ * Kept current while at least one {@link subscribeIntents} listener exists.
385
+ */
386
+ intentsSnapshot() {
387
+ return this.intentsSnapshotValue;
388
+ }
389
+ /** Observe intent changes; call {@link intentsSnapshot} for the new value. */
390
+ subscribeIntents(listener) {
391
+ this.intentListeners.add(listener);
392
+ if (this.intentListeners.size === 1)
393
+ this.refreshIntents();
394
+ return () => { this.intentListeners.delete(listener); };
395
+ }
396
+ /**
397
+ * Delivery status for one row from the current snapshot: `failed` or
398
+ * `rejected` when an intent touching it needs attention, `syncing` while one
399
+ * is still queued, otherwise undefined.
400
+ */
401
+ entityIntentStatus(entity, id) {
402
+ return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
403
+ }
404
+ /**
405
+ * Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
406
+ * original idempotency key is reused, so an attempt that had actually
407
+ * committed is replayed by the server rather than applied twice.
408
+ */
409
+ async retryIntent(id) {
410
+ await this.outboxReady;
411
+ const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
412
+ const scope = this.outboxScope;
413
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
414
+ if (!entry || scope !== this.outboxScope)
415
+ return false;
416
+ const updated = await this.reducerOutbox.retry(entry.id);
417
+ if (!updated)
418
+ return false;
419
+ this.optimisticOutboxEntryIds.set(id, updated.id);
420
+ if (!outboxEntryIsLive(entry)) {
421
+ // A rejection rolled the prediction back; restore it at its original
422
+ // position in the causal chain.
423
+ if (updated.localExecution && this.localExecutor)
424
+ await this.rebaseLocalEntriesLocked(true);
425
+ else
426
+ this.addOptimisticReducer(id, updated.patches ?? []);
427
+ }
428
+ return true;
429
+ }));
430
+ if (retried) {
431
+ this.unauthenticatedRetries = 0;
432
+ void this.drainOutbox();
433
+ }
434
+ return retried;
435
+ }
436
+ /**
437
+ * Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
438
+ * its optimistic prediction is removed and later local intents are rebased.
439
+ * Inflight and committed intents cannot be discarded. Discarding an intent
440
+ * whose earlier attempt timed out does not undo a commit that the server
441
+ * may already have applied; the replica then shows the server's truth.
442
+ */
443
+ async discardIntent(id) {
444
+ await this.outboxReady;
445
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
446
+ const scope = this.outboxScope;
447
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
448
+ if (!entry || scope !== this.outboxScope)
449
+ return false;
450
+ const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
451
+ if (!removed)
452
+ return false;
453
+ this.optimisticOutboxEntryIds.delete(id);
454
+ this.optimisticReducerIds.delete(id);
455
+ if (outboxEntryIsLive(removed)) {
456
+ this.replacingLocal = true;
457
+ try {
458
+ this.replica.rejectCommand(id);
459
+ }
460
+ finally {
461
+ this.replacingLocal = false;
462
+ }
463
+ if (removed.localExecution && this.localExecutor)
464
+ await this.rebaseLocalEntriesLocked(true);
465
+ }
466
+ return true;
467
+ }));
468
+ }
469
+ /**
470
+ * Discard the persisted Local Replica of the active identity (IndexedDB,
471
+ * Expo SQLite or any configured storage) and rehydrate it from the server.
472
+ *
473
+ * Online only: without an authenticated connection the call rejects with a
474
+ * `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
475
+ * user is never left with an empty cache that cannot be refilled. The saved
476
+ * offline session (identity and replica directive) is kept.
477
+ *
478
+ * By default the outbox is untouched: pending, failed and rejected intents
479
+ * stay durable, and the predictions of every live intent are re-applied
480
+ * immediately and recomputed as server data arrives. Active Replica
481
+ * Collections and Live Queries are re-requested without their old cursors,
482
+ * so the server sends full snapshots.
483
+ */
484
+ async resetLocalReplica(options = {}) {
485
+ const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
486
+ if (this.manuallyClosed)
487
+ throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
488
+ await this.outboxReady;
489
+ await this.replicaReady;
490
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
491
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
492
+ }
493
+ const keepOutbox = options.keepOutbox !== false;
494
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
495
+ const scope = this.outboxScope;
496
+ const replicaScope = this.replicaScope;
497
+ // Re-check under the lock: the connection may have dropped while waiting.
498
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
499
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
500
+ }
501
+ // Stop the server streaming into subscription ids we are about to retire.
502
+ for (const subscription of this.replicaSubscriptions.values()) {
503
+ if (subscription.socketGeneration !== undefined)
504
+ this.send({ type: "replica.close", id: subscription.id });
505
+ }
506
+ for (const subscription of this.querySubscriptions.values()) {
507
+ if (subscription.socketGeneration !== undefined)
508
+ this.send({ type: "query.unsubscribe", id: subscription.id });
509
+ }
510
+ this.pendingReplicaTransactions.length = 0;
511
+ await this.replica.clear(replicaScope);
512
+ this.resetReplicaScopeState();
513
+ this.rotateSubscriptionScopes();
514
+ let discardedIntents = 0;
515
+ if (!keepOutbox) {
516
+ for (const entry of await this.reducerOutbox.list(scope)) {
517
+ if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
518
+ discardedIntents += 1;
519
+ }
520
+ }
521
+ // replica.clear() dropped every overlay. Restore the stored predictions
522
+ // of live intents now; local intents are re-executed by the replay that
523
+ // each arriving snapshot schedules.
524
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
525
+ this.replacingLocal = true;
526
+ try {
527
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
528
+ }
529
+ finally {
530
+ this.replacingLocal = false;
531
+ }
532
+ this.optimisticReducerIds.clear();
533
+ this.optimisticOutboxEntryIds.clear();
534
+ for (const entry of entries) {
535
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
536
+ if (entry.state !== "committed" && entry.patches?.length)
537
+ this.optimisticReducerIds.add(entry.idempotencyKey);
538
+ }
539
+ let resubscribed = 0;
540
+ if (scope === this.outboxScope && replicaScope === this.replicaScope) {
541
+ for (const subscription of this.replicaSubscriptions.values())
542
+ if (subscription.listeners.size > 0)
543
+ resubscribed += 1;
544
+ for (const subscription of this.querySubscriptions.values())
545
+ if (subscription.listeners.size > 0)
546
+ resubscribed += 1;
547
+ this.resumeQuerySubscriptions(true);
548
+ this.resumeReplicaSubscriptions();
549
+ }
550
+ this.refreshIntents();
551
+ return { discardedIntents, resubscribed };
552
+ }));
553
+ }
554
+ /** Outbox owners with durable entries, including identities that never returned. */
555
+ async listOutboxScopes() {
556
+ await this.outboxReady;
557
+ const current = this.outboxScope;
558
+ return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
559
+ }
560
+ /**
561
+ * Permanently delete another identity's durable intents. The active scope
562
+ * cannot be purged this way; use {@link discardIntent} for its entries.
563
+ */
564
+ async purgeOutboxScope(scope) {
565
+ await this.outboxReady;
566
+ if (scope === this.outboxScope)
567
+ throw new Error("Cannot purge the active outbox scope");
568
+ return this.reducerOutbox.purgeScope(scope);
569
+ }
570
+ /** Purge every outbox scope except the current identity's. Never runs automatically. */
571
+ async purgeForeignOutboxScopes() {
572
+ let removed = 0;
573
+ for (const scope of await this.listOutboxScopes()) {
574
+ if (!scope.current)
575
+ removed += await this.purgeOutboxScope(scope.scope);
576
+ }
577
+ return removed;
578
+ }
579
+ refreshIntents() {
580
+ if (this.intentListeners.size === 0 || this.manuallyClosed)
581
+ return;
582
+ this.intentsRefreshDirty = true;
583
+ if (this.intentsRefreshRunning)
584
+ return;
585
+ this.intentsRefreshRunning = true;
586
+ void (async () => {
587
+ try {
588
+ while (this.intentsRefreshDirty && !this.manuallyClosed) {
589
+ this.intentsRefreshDirty = false;
590
+ await this.outboxReady.catch(() => undefined);
591
+ const scope = this.outboxScope;
592
+ const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
593
+ if (!entries || scope !== this.outboxScope) {
594
+ this.intentsRefreshDirty ||= scope !== this.outboxScope;
595
+ continue;
596
+ }
597
+ this.publishIntents(entries.map(outboxIntentFromEntry));
598
+ }
599
+ }
600
+ finally {
601
+ this.intentsRefreshRunning = false;
602
+ }
603
+ })();
604
+ }
605
+ publishIntents(next) {
606
+ if (sameIntents(this.intentsSnapshotValue, next))
607
+ return;
608
+ this.intentsSnapshotValue = next;
609
+ for (const listener of this.intentListeners) {
610
+ try {
611
+ listener();
612
+ }
613
+ catch { /* A listener must not break delivery. */ }
614
+ }
615
+ }
616
+ async restoreLocalSession(scope, generation) {
617
+ if (!this.localBinding || !scope.startsWith("identity\u0000"))
618
+ return;
619
+ const saved = await this.localStorage?.loadSession?.(scope);
620
+ if (!saved || generation !== this.outboxScopeGeneration || scope !== this.outboxScope || this.hasAuthoritativeReplicaScope)
621
+ return;
622
+ this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
623
+ this.localIdentity = saved.identity;
624
+ void this.localExecutor?.ready.catch(() => undefined);
625
+ await this.activateReplicaDirective(saved.directive);
626
+ this.replica.setFreshness("offline");
627
+ }
628
+ ensureLocalCollections(tables) {
629
+ if (!this.localBinding)
630
+ return;
631
+ const needed = new Set(tables);
632
+ for (const reference of this.localBinding.collections ?? []) {
633
+ if (!reference.replica || !needed.has(reference.replica.table))
634
+ continue;
635
+ const key = querySubscriptionKey(reference, {});
636
+ if (this.localCollectionKeys.has(key))
637
+ continue;
638
+ this.localCollectionKeys.add(key);
639
+ this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
640
+ }
641
+ }
642
+ hydrateMissingLocalTable(error) {
643
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
644
+ return;
645
+ // Worker errors preserve the public runtime name/message contract.
646
+ const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
647
+ if (table)
648
+ this.ensureLocalCollections([table]);
649
+ }
650
+ localSnapshot(includePending) {
651
+ const rows = this.replica.captureExecutionRows(includePending);
652
+ const tables = {};
653
+ for (const [table, binding] of this.localTableBindings) {
654
+ const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
655
+ tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
656
+ }
657
+ return { scope: this.replicaScope, tables };
658
+ }
659
+ localReadCoverage() {
660
+ return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
661
+ key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
662
+ complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
663
+ }])));
664
+ }
665
+ isUncoveredLocalRead(error) {
666
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
667
+ return false;
668
+ const read = error.read;
669
+ if (!read || read.limit === 0)
670
+ return false;
671
+ const known = this.localReadCoverage()[read.table];
672
+ // Disk can recover evicted primary keys or a complete registered slice.
673
+ // It cannot prove a scan over data that has never been delivered. Queue
674
+ // that intent for replay instead of waiting for unrelated cache writes.
675
+ return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
676
+ }
677
+ async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
678
+ if (this.localBinding?.mode === 'portable') {
679
+ try {
680
+ const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
681
+ let transaction;
682
+ try {
683
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
684
+ }
685
+ catch (error) {
686
+ if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
687
+ throw error;
688
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
689
+ }
690
+ // The read view already proved every read from the available replica
691
+ // windows or cached primary keys. Do not promote those bounded reads
692
+ // to whole-table subscriptions after a successful user edit. Missing
693
+ // coverage still opens its generated collection in the error path.
694
+ return transaction;
695
+ }
696
+ catch (error) {
697
+ // A speculative memory-only read may simply need IndexedDB. Its
698
+ // coordinated retry checks disk before deciding to request more data.
699
+ if (!residentOnly)
700
+ this.hydrateMissingLocalTable(error);
701
+ throw error;
702
+ }
703
+ }
704
+ if (!snapshot)
705
+ throw new Error('Legacy local executor requires a snapshot');
706
+ const known = this.localExecutionTables.get(path);
707
+ const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
708
+ let transaction;
709
+ try {
710
+ transaction = await this.localExecutor.execute(path, args, input, execution);
711
+ }
712
+ catch (error) {
713
+ this.hydrateMissingLocalTable(error);
714
+ if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
715
+ throw error;
716
+ // A different argument, tenant or workflow can read different tables.
717
+ // Retry against the SAME captured snapshot, never newer mixed-version rows.
718
+ try {
719
+ transaction = await this.localExecutor.execute(path, args, snapshot, execution);
720
+ }
721
+ catch (retryError) {
722
+ this.hydrateMissingLocalTable(retryError);
723
+ throw retryError;
724
+ }
725
+ }
726
+ this.ensureLocalCollections(transaction.readTables);
727
+ this.localExecutionTables.set(path, new Set([
728
+ ...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
729
+ ]));
730
+ return transaction;
731
+ }
732
+ inLocalLane(run) {
733
+ const job = this.localLane.then(run);
734
+ this.localLane = job.catch(() => undefined);
735
+ return job;
736
+ }
737
+ replaceLocalPredictions(commands) {
738
+ this.replica.replaceOptimistic(commands);
739
+ }
740
+ coordinateOutbox(lane, run) {
741
+ return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
742
+ }
743
+ refreshPeerOutbox() {
744
+ if (this.manuallyClosed)
745
+ return;
746
+ this.peerRefreshDirty = true;
747
+ if (this.peerRefreshScheduled)
748
+ return;
749
+ this.peerRefreshScheduled = true;
750
+ void (async () => {
751
+ await this.outboxReady;
752
+ do {
753
+ this.peerRefreshDirty = false;
754
+ await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
755
+ const scope = this.outboxScope;
756
+ await this.replica.synchronizeStorage();
757
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
758
+ if (this.manuallyClosed || scope !== this.outboxScope)
759
+ return;
760
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
761
+ this.optimisticReducerIds.clear();
762
+ this.optimisticOutboxEntryIds.clear();
763
+ for (const entry of entries) {
764
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
765
+ if (entry.patches?.length)
766
+ this.optimisticReducerIds.add(entry.idempotencyKey);
767
+ }
768
+ }));
769
+ } while (this.peerRefreshDirty && !this.manuallyClosed);
770
+ this.refreshIntents();
771
+ void this.drainOutbox();
772
+ })().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
773
+ }
774
+ scheduleLocalReplay() {
775
+ if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
776
+ return;
777
+ // Restore and peer notifications discover durable intents explicitly. A
778
+ // table arriving at startup cannot invalidate a prediction that does not exist.
779
+ if (this.optimisticOutboxEntryIds.size === 0)
780
+ return;
781
+ this.localReplayScheduled = true;
782
+ queueMicrotask(() => {
783
+ void (async () => {
784
+ await this.outboxReady;
785
+ await this.inLocalLane(() => this.rebaseLocalEntries());
786
+ })().catch(() => undefined).finally(() => { this.localReplayScheduled = false; });
787
+ });
788
+ }
789
+ reportLocalRejection(reducerId, path, error) {
790
+ const message = reducerErrorMessage(error);
791
+ for (const listener of this.reducerRejectionHandlers) {
792
+ try {
793
+ listener({ reducerId, path, error: message, errorClass: "rejected" });
794
+ }
795
+ catch { /* A listener must not break delivery. */ }
796
+ }
797
+ }
798
+ rebaseLocalEntries(afterRejection = false) {
799
+ return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
800
+ }
801
+ async rebaseLocalEntriesLocked(afterRejection = false) {
802
+ if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
803
+ return;
804
+ // The authoritative drain validates the pending chain in order. Keep its
805
+ // predictions while it progresses, then rebase once it settles or loses
806
+ // connectivity instead of replaying N + (N-1) + ... local transactions.
807
+ if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
808
+ return;
809
+ await this.replica.synchronizeStorage();
810
+ const scope = this.outboxScope;
811
+ const replicaScope = this.replicaScope;
812
+ // Rejected records keep their payload for the UI but are never predicted.
813
+ // Failed (parked) intents stay predicted until retried or discarded.
814
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
815
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
816
+ return;
817
+ if (entries.length === 0) {
818
+ this.replacingLocal = true;
819
+ try {
820
+ this.replaceLocalPredictions([]);
821
+ }
822
+ finally {
823
+ this.replacingLocal = false;
824
+ }
825
+ this.optimisticReducerIds.clear();
826
+ this.optimisticOutboxEntryIds.clear();
827
+ return;
828
+ }
829
+ const baseVersion = this.replica.executionVersion();
830
+ const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
831
+ const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
832
+ const replayPatches = [];
833
+ const commands = [];
834
+ for (const entry of entries) {
835
+ // Predictions can wait; a fresh user intent must not sit behind the
836
+ // entire durable backlog. Publish only a complete, coherent rebase.
837
+ if (this.waitingLocalEdits > 0) {
838
+ setTimeout(() => this.scheduleLocalReplay(), 0);
839
+ return;
840
+ }
841
+ let patches = entry.patches ?? [];
842
+ if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
843
+ const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
844
+ try {
845
+ const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
846
+ patches = transaction.patches;
847
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
848
+ return;
849
+ }
850
+ catch (error) {
851
+ // Re-execution is a prediction. Only an authoritative server rejection
852
+ // removes a durable intent; missing data or local validation can make
853
+ // its predicted transaction unavailable without rejecting the intent.
854
+ if (patches.length && scope === this.outboxScope && replicaScope === this.replicaScope)
855
+ this.reportLocalFallback(entry.path, entry.idempotencyKey, "replay", error);
856
+ patches = [];
857
+ }
858
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
859
+ return;
860
+ // Storage failures must abort rebasing, never masquerade as rejection.
861
+ await this.reducerOutbox.updateLocal(entry.id, patches, execution);
862
+ }
863
+ if (snapshot)
864
+ applyLocalPatches(snapshot, patches);
865
+ for (const patch of patches)
866
+ replayPatches.push(patch.op === 'delete'
867
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
868
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
869
+ commands.push({ commandId: entry.idempotencyKey, patches });
870
+ }
871
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
872
+ return;
873
+ if (baseVersion !== this.replica.executionVersion()) {
874
+ // A changing server base must not recursively occupy the local lane.
875
+ // Release it so user intents already queued can run before the retry.
876
+ setTimeout(() => this.scheduleLocalReplay(), 0);
877
+ return;
878
+ }
879
+ this.replacingLocal = true;
880
+ try {
881
+ this.replaceLocalPredictions(commands);
882
+ }
883
+ finally {
884
+ this.replacingLocal = false;
885
+ }
886
+ this.optimisticReducerIds.clear();
887
+ this.optimisticOutboxEntryIds.clear();
888
+ for (const entry of entries) {
889
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
890
+ }
891
+ for (const command of commands)
892
+ if (command.patches.length)
893
+ this.optimisticReducerIds.add(command.commandId);
894
+ }
895
+ async runLocalReducer(ref, args) {
896
+ args = structuredClone(args);
897
+ const admittedGeneration = this.outboxScopeGeneration;
898
+ await this.outboxReady;
899
+ await this.replicaReady;
900
+ if (!this.localExecutor || !this.localBinding)
901
+ throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
902
+ const reducerId = randomID();
903
+ const execution = this.localIdentity ? {
904
+ scope: this.replicaScope, commandId: reducerId, now: Date.now(),
905
+ intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
906
+ artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
907
+ } : undefined;
908
+ let preparedResult;
909
+ let preparedFailure;
910
+ const prepared = this.preparationLane.then(async () => {
911
+ if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
912
+ return;
913
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
914
+ return;
915
+ const base = this.replica.executionVersion();
916
+ const pending = this.replica.pendingReducerPatches();
917
+ try {
918
+ const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
919
+ if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
920
+ return;
921
+ preparedResult = { transaction, base, pending: JSON.stringify(pending) };
922
+ }
923
+ catch (error) {
924
+ preparedFailure = error; /* Recheck coverage under coordinated admission. */
925
+ }
926
+ });
927
+ this.preparationLane = prepared.catch(() => undefined);
928
+ this.waitingLocalEdits += 1;
929
+ let entered = false;
930
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
931
+ entered = true;
932
+ this.waitingLocalEdits -= 1;
933
+ await prepared;
934
+ if (admittedGeneration !== this.outboxScopeGeneration)
935
+ throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
936
+ if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
937
+ throw new Error("Restore an authenticated local session before editing offline");
938
+ if (this.manuallyClosed)
939
+ throw new Error("Gonvex client is closed");
940
+ if (this.updateRequired)
941
+ throw new Error("Application update required before editing");
942
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
943
+ throw new Error("Offline editing window expired. Reconnect before making more changes.");
944
+ const scope = this.outboxScope;
945
+ if (!execution || execution.scope !== this.replicaScope)
946
+ throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
947
+ const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
948
+ const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
949
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
950
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
951
+ // A fully resident prediction reads the already published atomic replica.
952
+ // Unrelated snapshots may still be persisting; waiting for those here
953
+ // makes a small edit inherit their disk latency. The shared intent lock
954
+ // and journal still order every edit, and known peer changes must be
955
+ // reconciled before a resident prediction can be reused.
956
+ const reuseResident = preparedResult
957
+ && preparedResult.base === this.replica.executionVersion()
958
+ && preparedResult.pending === JSON.stringify(sharedPatches)
959
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
960
+ && !sharedEntries.some(entry => entry.state === "committed");
961
+ const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
962
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
963
+ && !sharedEntries.some(entry => entry.state === "committed");
964
+ if (!reuseResident && !uncoveredRead)
965
+ await this.replica.synchronizeStorage();
966
+ if (sharedEntries)
967
+ this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
968
+ const executionBaseVersion = this.replica.executionVersion();
969
+ let transaction;
970
+ let fallback;
971
+ try {
972
+ if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
973
+ transaction = preparedResult.transaction;
974
+ }
975
+ else if (this.localBinding.mode === 'portable') {
976
+ // A commit may invalidate preparation while the intent lock is being
977
+ // acquired. Read the newly published atomic replica again before
978
+ // joining disk work that arrived after synchronizeStorage completed.
979
+ try {
980
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
981
+ }
982
+ catch (error) {
983
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
984
+ throw error;
985
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
986
+ }
987
+ }
988
+ else {
989
+ transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
990
+ }
991
+ }
992
+ catch (error) {
993
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
994
+ throw error;
995
+ fallback = error;
996
+ }
997
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope)
998
+ throw new GonvexClientError("Session changed during local reducer execution", { code: "superseded" });
999
+ const patches = transaction?.patches ?? [];
1000
+ this.directOutboxReducerIds.add(reducerId);
1001
+ try {
1002
+ // A visible prediction must already have a durable intent. Otherwise a
1003
+ // reload immediately after paint can lose an offline edit. Local
1004
+ // execution still avoids a server round trip; disk admission is bounded
1005
+ // to this intent, rather than persisting the whole replica.
1006
+ const entry = await this.reducerOutbox.enqueue({
1007
+ scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
1008
+ // All local intents form one causal chain, including reads of rows
1009
+ // written by a previous intent, not only overlapping write patches.
1010
+ entityKeys: ["__gonvex_local_intents"], patches, localExecution: execution,
1011
+ });
1012
+ if (scope !== this.outboxScope || execution.scope !== this.replicaScope) {
1013
+ throw new GonvexClientError("Session changed while persisting local reducer", { code: "superseded" });
1014
+ }
1015
+ this.optimisticOutboxEntryIds.set(reducerId, entry.id);
1016
+ this.replacingLocal = true;
1017
+ try {
1018
+ this.addOptimisticReducer(reducerId, patches);
1019
+ }
1020
+ finally {
1021
+ this.replacingLocal = false;
1022
+ }
1023
+ if (!transaction) {
1024
+ this.reportLocalFallback(ref.path, reducerId, "call", fallback);
1025
+ return { status: "queued", reducerId };
1026
+ }
1027
+ return transaction.result;
1028
+ }
1029
+ catch (error) {
1030
+ this.optimisticReducerIds.delete(reducerId);
1031
+ this.replica.rejectCommand(reducerId);
1032
+ throw error;
1033
+ }
1034
+ finally {
1035
+ this.directOutboxReducerIds.delete(reducerId);
1036
+ // This transaction already executed against all earlier predictions.
1037
+ // Appending it does not invalidate those predictions. Rebase only if
1038
+ // the authoritative execution snapshot changed during execution.
1039
+ // An uncovered read has no new data to replay against yet. Its
1040
+ // hydration subscription schedules replay when coverage arrives;
1041
+ // immediately retrying here only locks edits behind the same disk work.
1042
+ if (this.replica.executionVersion() !== executionBaseVersion)
1043
+ this.scheduleLocalReplay();
1044
+ void this.drainOutbox();
1045
+ }
1046
+ })).catch(error => {
1047
+ if (!entered)
1048
+ this.waitingLocalEdits -= 1;
1049
+ this.optimisticReducerIds.delete(reducerId);
1050
+ this.replica.rejectCommand(reducerId);
1051
+ throw error;
1052
+ });
1053
+ }
1054
+ /** Preload code for a mounted control without reading data or executing an intent. */
1055
+ prepareReducer(ref) {
1056
+ if (ref.localExecution !== 1 || this.manuallyClosed)
1057
+ return Promise.resolve();
1058
+ return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
232
1059
  }
233
1060
  /** The single normalized authoritative + optimistic application data store. */
234
1061
  get localReplica() {
@@ -245,19 +1072,33 @@ export class GonvexClient {
245
1072
  replicaEntities(entity, ids) {
246
1073
  return this.replica.entityBatch(entity, ids);
247
1074
  }
1075
+ /** Retain only the data an active view observes; cold rows remain on disk. */
1076
+ retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
1077
+ retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
248
1078
  /** Read rows and server-owned completeness for a persisted Replica Collection. */
249
1079
  replicaCollectionState(ref, args = {}) {
250
1080
  return this.replica.collectionState(this.replicaSignature(ref, args));
251
1081
  }
252
1082
  /** Run the generated Live Query plan over the bounded normalized cache. */
253
- offlineLiveQuery(ref, args = {}) {
1083
+ offlineLiveQuery(ref, args = {}, options = {}) {
254
1084
  if (!ref.live?.plan) {
255
1085
  return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
256
1086
  }
257
1087
  const queryArgs = isJsonRecord(args) ? args : {};
258
- return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity));
1088
+ 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), {
1089
+ context: {
1090
+ ...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
1091
+ ...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
1092
+ ...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
1093
+ },
1094
+ relationRows: (table, where) => {
1095
+ const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
1096
+ return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
1097
+ && readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
1098
+ },
1099
+ });
259
1100
  }
260
- /** Number of reducers waiting for a definitive server result. */
1101
+ /** Number of reducers still queued for delivery (excludes failed and rejected intents). */
261
1102
  async outboxCount() {
262
1103
  await this.outboxReady;
263
1104
  return this.reducerOutbox.count(this.outboxScope);
@@ -374,7 +1215,24 @@ export class GonvexClient {
374
1215
  this.authErrorHandlers.delete(handler);
375
1216
  };
376
1217
  }
1218
+ /**
1219
+ * Subscribe to rotated developer-mode reconnect credentials. The runtime
1220
+ * consumes each developer credential when it accepts a connection and
1221
+ * returns a single-use successor; the client already uses that successor
1222
+ * for its own reconnects. Auth providers subscribe so they can keep the
1223
+ * newest credential in tab-scoped storage and resume developer mode after a
1224
+ * page reload. The value is a secret: never log it or put it in a URL.
1225
+ */
1226
+ onDeveloperCredential(handler) {
1227
+ this.developerCredentialHandlers.add(handler);
1228
+ return () => {
1229
+ this.developerCredentialHandlers.delete(handler);
1230
+ };
1231
+ }
377
1232
  applyAuth(auth) {
1233
+ if (this.publicTenant && (auth.token || auth.fetchToken || auth.identity || auth.tenant !== undefined && auth.tenant !== this.publicTenant || auth.project !== undefined && auth.project !== this.auth.project)) {
1234
+ throw new Error("Public connection scope is fixed; create another client to sign in or change tenants");
1235
+ }
378
1236
  const nextAuth = { ...this.auth, ...auth };
379
1237
  const tokenScopeChanged = hasOwn(auth, "token")
380
1238
  && auth.token !== this.auth.token
@@ -416,12 +1274,20 @@ export class GonvexClient {
416
1274
  }
417
1275
  }
418
1276
  connect() {
1277
+ if (this.updateRequired)
1278
+ return;
419
1279
  if (this.socket && this.socket.readyState <= WebSocket.OPEN)
420
1280
  return;
421
1281
  const isReconnect = this.socket !== undefined;
422
1282
  this.manuallyClosed = false;
423
1283
  const generation = ++this.socketGeneration;
424
- const socket = new WebSocket(this.url);
1284
+ let transportUrl = this.url;
1285
+ if (this.publicTenant) {
1286
+ const publicUrl = new URL(this.url);
1287
+ publicUrl.searchParams.set("public", "1");
1288
+ transportUrl = publicUrl.toString();
1289
+ }
1290
+ const socket = new WebSocket(transportUrl);
425
1291
  this.socket = socket;
426
1292
  socket.addEventListener("open", () => {
427
1293
  if (this.socket !== socket)
@@ -504,6 +1370,10 @@ export class GonvexClient {
504
1370
  }
505
1371
  return;
506
1372
  }
1373
+ if (message.type === "client.updateRequired") {
1374
+ this.requireClientUpdate(message.reason);
1375
+ return;
1376
+ }
507
1377
  if (message.type === "system.reload") {
508
1378
  if (typeof message.artifactHash === "string") {
509
1379
  this.activeArtifactHashValue = message.artifactHash;
@@ -537,7 +1407,7 @@ export class GonvexClient {
537
1407
  this.sendAuth(true, { useFetcher: false });
538
1408
  return;
539
1409
  }
540
- const fetcher = this.auth.fetchToken;
1410
+ const fetcher = this.publicTenant ? undefined : this.auth.fetchToken;
541
1411
  if (fetcher) {
542
1412
  this.authInFlight = true;
543
1413
  this.authRetriedAfterError = true;
@@ -577,6 +1447,8 @@ export class GonvexClient {
577
1447
  this.activeAuthFrameId = message.id;
578
1448
  else
579
1449
  this.activeAuthFrameId = undefined;
1450
+ if (message.type === "auth.result")
1451
+ this.flushPendingNativeErrors();
580
1452
  if (this.authWatchdogTimer) {
581
1453
  clearTimeout(this.authWatchdogTimer);
582
1454
  this.authWatchdogTimer = undefined;
@@ -584,18 +1456,27 @@ export class GonvexClient {
584
1456
  if (message.type === "auth.result") {
585
1457
  const reauthenticatedSameSocket = this.authenticatedSocketGeneration === this.socketGeneration;
586
1458
  this.authenticatedSocketGeneration = this.socketGeneration;
1459
+ this.lastOnlineAtMs = Date.now();
587
1460
  this.activeArtifactHashValue = artifactHashFromAuthResult(message.result) ?? this.activeArtifactHashValue;
588
1461
  const developerSessionToken = developerSessionTokenFromAuthResult(message.result);
589
1462
  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.
1463
+ // The activation token is single-use. Use its rotating successor
1464
+ // for the next reconnect, and hand it to the provider that owns
1465
+ // developer mode so a reload can resume the same grant.
592
1466
  this.auth = { ...this.auth, token: developerSessionToken, fetchToken: undefined };
1467
+ for (const handler of Array.from(this.developerCredentialHandlers))
1468
+ handler(developerSessionToken);
593
1469
  }
594
1470
  this.authRetriedAfterError = false;
595
1471
  const directive = replicaDirectiveFromAuthResult(message.result);
1472
+ const localIdentity = localIdentityFromAuthResult(message.result);
1473
+ if (localIdentity) {
1474
+ this.localIdentity = localIdentity;
1475
+ void this.localExecutor?.ready.catch(() => undefined);
1476
+ }
596
1477
  if (!directive) {
597
1478
  this.authInFlight = false;
598
- if (!this.auth.tenant) {
1479
+ if (!this.auth.tenant || this.publicTenant) {
599
1480
  this.resumeQuerySubscriptions(reauthenticatedSameSocket);
600
1481
  this.settleManagedAuthAttempt(message.id);
601
1482
  }
@@ -612,6 +1493,12 @@ export class GonvexClient {
612
1493
  }
613
1494
  void this.activateReplicaDirective(directive)
614
1495
  .then(() => this.activateOutboxScope())
1496
+ .then(async () => {
1497
+ if (localIdentity && this.localBinding)
1498
+ await this.localStorage?.saveSession?.(this.outboxScope, {
1499
+ directive, identity: localIdentity, artifactHash: this.activeArtifactHashValue ?? this.localBinding.artifactHash, lastOnlineAtMs: this.lastOnlineAtMs,
1500
+ });
1501
+ })
615
1502
  .then(() => {
616
1503
  // The accepted server identity is not usable until its durable
617
1504
  // Replica partition has been activated locally. Only now may
@@ -725,6 +1612,11 @@ export class GonvexClient {
725
1612
  }
726
1613
  close() {
727
1614
  this.manuallyClosed = true;
1615
+ this.unsubscribeLocal?.();
1616
+ for (const close of this.localCollectionClosers.splice(0))
1617
+ close();
1618
+ this.localCollectionKeys.clear();
1619
+ this.localExecutor?.close();
728
1620
  this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
729
1621
  if (isEphemeralOutboxScope(this.outboxScope)) {
730
1622
  void this.reducerOutbox.clear(this.outboxScope);
@@ -760,12 +1652,15 @@ export class GonvexClient {
760
1652
  this.outboxDrainTimer = undefined;
761
1653
  }
762
1654
  this.unsubscribeOutbox();
1655
+ this.unsubscribePeerOutbox?.();
1656
+ this.unsubscribePeerReplica?.();
763
1657
  this.unsubscribeBrowserOnline?.();
764
1658
  this.handlers.clear();
765
1659
  this.querySubscriptions.clear();
766
1660
  this.replicaSubscriptions.clear();
767
1661
  this.sessionScopeHandlers.clear();
768
1662
  this.authErrorHandlers.clear();
1663
+ this.developerCredentialHandlers.clear();
769
1664
  // Invalidate any token fetch still in flight so its resolve can't touch
770
1665
  // the closed client's caches.
771
1666
  this.authFetchGeneration += 1;
@@ -777,6 +1672,10 @@ export class GonvexClient {
777
1672
  this.notifyConnectionState();
778
1673
  this.connectionStateHandlers.clear();
779
1674
  this.supportCommandHandlers.clear();
1675
+ this.replica.dispose();
1676
+ const closedError = new Error("Gonvex client is closed");
1677
+ for (const pending of this.pendingNativeErrors.splice(0))
1678
+ pending.reject(closedError);
780
1679
  if (!socket)
781
1680
  return;
782
1681
  socket.close();
@@ -1110,7 +2009,9 @@ export class GonvexClient {
1110
2009
  // could overwrite a result already accepted on the same connection.
1111
2010
  return subscription.revisionSocketGeneration !== this.socketGeneration;
1112
2011
  }
1113
- subscribeReplicaTransport(ref, args = {}, onMessage) {
2012
+ subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
2013
+ if (metadataOnly)
2014
+ this.replicaMetadataListeners.add(onMessage);
1114
2015
  this.connect();
1115
2016
  const key = querySubscriptionKey(ref, args);
1116
2017
  const existing = this.replicaSubscriptions.get(key);
@@ -1120,10 +2021,14 @@ export class GonvexClient {
1120
2021
  existing.unsubscribeTimer = undefined;
1121
2022
  }
1122
2023
  existing.listeners.add(onMessage);
2024
+ // A reset may arrive during the retention grace period with no listeners.
2025
+ // Its reopen was correctly deferred; the next subscriber must resume it.
2026
+ if (!existing.isUpToDate && !existing.opening)
2027
+ this.sendReplicaOpen(existing);
1123
2028
  if (existing.lastMessage) {
1124
2029
  queueMicrotask(() => {
1125
2030
  if (existing.listeners.has(onMessage) && existing.lastMessage) {
1126
- onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
2031
+ onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
1127
2032
  }
1128
2033
  });
1129
2034
  }
@@ -1161,12 +2066,16 @@ export class GonvexClient {
1161
2066
  return this.subscribeReplicaTransport(ref, args, onMessage);
1162
2067
  }
1163
2068
  /** Watch a bounded Replica Collection through the normalized Local Replica. */
1164
- watchReplica(ref, args = {}) {
2069
+ watchReplica(ref, args = {}, options = {}) {
1165
2070
  const key = querySubscriptionKey(ref, args);
1166
2071
  const updateHandlers = new Set();
1167
2072
  let latestError;
1168
2073
  let snapshotVersion = -1;
1169
2074
  let snapshotRows;
2075
+ const readRows = () => {
2076
+ const next = this.replica.liveQuerySnapshot(key).rows;
2077
+ return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
2078
+ };
1170
2079
  let stateVersion = -1;
1171
2080
  let stateFreshness;
1172
2081
  let stateIsUpToDate;
@@ -1178,45 +2087,57 @@ export class GonvexClient {
1178
2087
  };
1179
2088
  // Keep the ReplicaSubscription as transport/reconciliation state only. The
1180
2089
  // 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;
2090
+ let stop;
2091
+ const start = () => {
2092
+ if (stop)
2093
+ return;
1201
2094
  snapshotVersion = -1;
1202
- snapshotRows = undefined;
1203
2095
  stateVersion = -1;
1204
- stateFreshness = undefined;
1205
- stateIsUpToDate = undefined;
1206
- snapshotState = undefined;
1207
- notify();
1208
- });
2096
+ const releaseRows = this.replica.retainWindow(key);
2097
+ const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
2098
+ if (message.type === "replica.error") {
2099
+ latestError = new Error(message.error);
2100
+ notify();
2101
+ }
2102
+ else if (message.type === "replica.syncing" || message.type === "replica.reset") {
2103
+ latestError = undefined;
2104
+ notify();
2105
+ }
2106
+ else if (message.type === "replica.ready") {
2107
+ latestError = undefined;
2108
+ notify();
2109
+ }
2110
+ else if (message.type === "replica.snapshot") {
2111
+ latestError = undefined;
2112
+ }
2113
+ }, true);
2114
+ const unsubscribeReplica = this.replica.subscribe(notify);
2115
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2116
+ latestError = undefined;
2117
+ snapshotVersion = -1;
2118
+ snapshotRows = undefined;
2119
+ stateVersion = -1;
2120
+ stateFreshness = undefined;
2121
+ stateIsUpToDate = undefined;
2122
+ snapshotState = undefined;
2123
+ notify();
2124
+ });
2125
+ stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2126
+ };
2127
+ // React can discard a render before subscribing. Such watches must own no resources.
2128
+ if (!options.deferStart)
2129
+ start();
1209
2130
  return {
1210
2131
  localReplicaResult: () => {
1211
2132
  if (latestError)
1212
2133
  throw latestError;
1213
2134
  if (!this.replica.hasLiveQuery(key))
1214
2135
  return undefined;
1215
- const version = this.replica.windowVersion(key);
2136
+ const version = this.replica.windowRowsVersion(key);
1216
2137
  if (snapshotVersion === version)
1217
2138
  return snapshotRows;
1218
2139
  snapshotVersion = version;
1219
- snapshotRows = this.replica.liveQuery(key).rows;
2140
+ snapshotRows = readRows();
1220
2141
  return snapshotRows;
1221
2142
  },
1222
2143
  localReplicaState: () => {
@@ -1234,7 +2155,12 @@ export class GonvexClient {
1234
2155
  stateVersion = version;
1235
2156
  stateFreshness = freshness;
1236
2157
  stateIsUpToDate = isUpToDate;
1237
- const state = this.replica.collectionState(key);
2158
+ const rowsVersion = this.replica.windowRowsVersion(key);
2159
+ if (snapshotVersion !== rowsVersion) {
2160
+ snapshotVersion = rowsVersion;
2161
+ snapshotRows = readRows();
2162
+ }
2163
+ const state = this.replica.collectionState(key, snapshotRows);
1238
2164
  snapshotState = {
1239
2165
  ...state,
1240
2166
  isUpToDate,
@@ -1250,6 +2176,7 @@ export class GonvexClient {
1250
2176
  isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
1251
2177
  }),
1252
2178
  onUpdate(handler) {
2179
+ start();
1253
2180
  if (releaseTimer) {
1254
2181
  clearTimeout(releaseTimer);
1255
2182
  releaseTimer = undefined;
@@ -1267,9 +2194,7 @@ export class GonvexClient {
1267
2194
  releaseTimer = undefined;
1268
2195
  if (updateHandlers.size > 0)
1269
2196
  return;
1270
- unsubscribeTransport();
1271
- unsubscribeReplica();
1272
- unsubscribeScope();
2197
+ stop?.();
1273
2198
  }, 0);
1274
2199
  };
1275
2200
  },
@@ -1280,7 +2205,7 @@ export class GonvexClient {
1280
2205
  * latest query result is retained only as the transport-shaped skeleton;
1281
2206
  * its row window is always rebuilt from LocalReplica membership/entities.
1282
2207
  */
1283
- watchLiveQuery(ref, args = {}) {
2208
+ watchLiveQuery(ref, args = {}, options = {}) {
1284
2209
  const key = querySubscriptionKey(ref, args);
1285
2210
  const updateHandlers = new Set();
1286
2211
  let transportResult;
@@ -1300,39 +2225,51 @@ export class GonvexClient {
1300
2225
  handler();
1301
2226
  });
1302
2227
  };
1303
- const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
1304
- if (message.type === "query.result") {
1305
- transportResult = message.result;
2228
+ let stop;
2229
+ const start = () => {
2230
+ if (stop)
2231
+ return;
2232
+ transportResult = undefined;
2233
+ snapshotToken = "";
2234
+ const releaseRows = this.replica.retainWindow(key);
2235
+ const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
2236
+ if (message.type === "query.result") {
2237
+ transportResult = message.result;
2238
+ transportGeneration += 1;
2239
+ latestError = undefined;
2240
+ // LocalReplica has already published its atomic window swap before
2241
+ // this callback is emitted, so this is the single initial UI wake-up.
2242
+ notify();
2243
+ }
2244
+ else if (message.type === "query.error") {
2245
+ latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
2246
+ code: "server", path: ref.path, operation: "query",
2247
+ });
2248
+ notify();
2249
+ }
2250
+ });
2251
+ // During the initial query result, LocalReplica notifies before the
2252
+ // transport-shaped skeleton is installed above. Suppress that empty
2253
+ // intermediate wake-up; later transactions notify directly from the
2254
+ // normalized store.
2255
+ const unsubscribeReplica = this.replica.subscribe(() => {
2256
+ if (transportResult !== undefined || this.replica.hasLiveQuery(key))
2257
+ notify();
2258
+ });
2259
+ void this.replicaReady.then(() => notify());
2260
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2261
+ transportResult = undefined;
1306
2262
  transportGeneration += 1;
2263
+ snapshotToken = "";
2264
+ snapshotResult = undefined;
1307
2265
  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
- 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
2266
  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
- });
2267
+ });
2268
+ stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2269
+ };
2270
+ // React can discard a render before subscribing. Such watches must own no resources.
2271
+ if (!options.deferStart)
2272
+ start();
1336
2273
  return {
1337
2274
  localLiveQueryResult: () => {
1338
2275
  if (latestError)
@@ -1367,6 +2304,7 @@ export class GonvexClient {
1367
2304
  return snapshotResult;
1368
2305
  },
1369
2306
  onUpdate(handler) {
2307
+ start();
1370
2308
  if (releaseTimer) {
1371
2309
  clearTimeout(releaseTimer);
1372
2310
  releaseTimer = undefined;
@@ -1384,9 +2322,7 @@ export class GonvexClient {
1384
2322
  releaseTimer = undefined;
1385
2323
  if (updateHandlers.size > 0)
1386
2324
  return;
1387
- unsubscribeQuery();
1388
- unsubscribeReplica();
1389
- unsubscribeScope();
2325
+ stop?.();
1390
2326
  }, 0);
1391
2327
  };
1392
2328
  },
@@ -1431,7 +2367,7 @@ export class GonvexClient {
1431
2367
  scope,
1432
2368
  };
1433
2369
  await this.replica.replaceWindow(window);
1434
- const snapshot = { ...message, result: this.replica.windowRows(subscription.key) };
2370
+ const snapshot = { ...message, result: [] };
1435
2371
  subscription.lastMessage = snapshot;
1436
2372
  this.emitReplicaMessage(subscription, snapshot, scope);
1437
2373
  return;
@@ -1442,12 +2378,20 @@ export class GonvexClient {
1442
2378
  return;
1443
2379
  this.clearReplicaRetry(subscription, true);
1444
2380
  raiseReplicaCursorFloor(subscription, message.cursor);
2381
+ const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
2382
+ // Incremental integrity is safe only for full projected row images. Older
2383
+ // partial-row protocols keep the full normalized rehash on replica.ready.
2384
+ const canCarryHashes = prior?.hashes && subscription.columns?.length
2385
+ && upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
2386
+ const hashes = message.hashes ?? (canCarryHashes
2387
+ ? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
2388
+ : undefined);
1445
2389
  await this.replica.applyWindowDelta({
1446
2390
  signature: subscription.key,
1447
2391
  kind: "replica",
1448
2392
  entity: subscription.entity,
1449
2393
  key: prior?.key ?? "id",
1450
- upserts: (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
2394
+ upserts,
1451
2395
  deleted: message.deleted ?? [],
1452
2396
  completeness: prior?.completeness ?? "partial",
1453
2397
  source: "server",
@@ -1458,13 +2402,11 @@ export class GonvexClient {
1458
2402
  orderDirection: prior?.orderDirection,
1459
2403
  maxRows: prior?.maxRows,
1460
2404
  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,
2405
+ hashes,
1464
2406
  });
1465
2407
  const snapshot = {
1466
2408
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1467
- result: this.replica.windowRows(subscription.key), cursor: message.cursor,
2409
+ result: [], cursor: message.cursor,
1468
2410
  key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
1469
2411
  orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
1470
2412
  };
@@ -1614,9 +2556,15 @@ export class GonvexClient {
1614
2556
  emitReplicaMessage(subscription, message, scope = this.replicaScope) {
1615
2557
  if (scope !== this.replicaScope || subscription.scope !== scope)
1616
2558
  return;
1617
- const outgoing = this.materializeReplicaMessage(subscription, message);
1618
- for (const listener of Array.from(subscription.listeners))
1619
- listener(outgoing);
2559
+ // Hook observers consume metadata only; their rows come from Local Replica.
2560
+ // Materialize a payload only when a public transport consumer needs it.
2561
+ let outgoing;
2562
+ for (const listener of Array.from(subscription.listeners)) {
2563
+ if (this.replicaMetadataListeners.has(listener))
2564
+ listener(message);
2565
+ else
2566
+ listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
2567
+ }
1620
2568
  }
1621
2569
  materializeReplicaMessage(subscription, message) {
1622
2570
  if (message.type !== "replica.snapshot")
@@ -1662,7 +2610,7 @@ export class GonvexClient {
1662
2610
  subscription.isUpToDate = false;
1663
2611
  const message = {
1664
2612
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1665
- result: this.replica.windowRows(subscription.key), cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
2613
+ result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
1666
2614
  key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
1667
2615
  orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
1668
2616
  };
@@ -1771,8 +2719,11 @@ export class GonvexClient {
1771
2719
  this.outboxScope = scope;
1772
2720
  const ready = this.hasAuthoritativeReplicaScope
1773
2721
  ? this.restoreOutbox(scope, generation)
1774
- : Promise.resolve();
1775
- this.outboxReady = ready;
2722
+ : this.restoreLocalSession(scope, generation);
2723
+ this.setOutboxReady(ready);
2724
+ // Never show the previous identity's intents under the new one.
2725
+ this.publishIntents([]);
2726
+ this.refreshIntents();
1776
2727
  return ready;
1777
2728
  }
1778
2729
  async activateReplicaDirective(directive) {
@@ -1795,8 +2746,11 @@ export class GonvexClient {
1795
2746
  this.resetReplicaScopeState();
1796
2747
  this.replicaScope = scope;
1797
2748
  this.hasAuthoritativeReplicaScope = true;
1798
- this.replicaReady = this.replica.activateScope(scope);
2749
+ this.setReplicaReady(this.replica.activateScope(scope));
1799
2750
  this.rotateSubscriptionScopes();
2751
+ // Subscriptions follow scope rotation, so this opens each resident
2752
+ // dependency collection once per client, from cache first when offline.
2753
+ this.ensureLocalCollections(this.residentDependencyTables);
1800
2754
  const generation = this.outboxScopeGeneration;
1801
2755
  // Publish the recovery barrier before yielding to Replica storage. A
1802
2756
  // reducer may be invoked as soon as the auth result arrives, while the
@@ -1804,10 +2758,24 @@ export class GonvexClient {
1804
2758
  // points at the old resolved promise, that reducer can enqueue an inflight
1805
2759
  // row which the concurrent recovery then mistakes for an abandoned call
1806
2760
  // and sends a second time with the same command ID.
1807
- this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
2761
+ this.setOutboxReady(this.restoreOutbox(this.outboxScope, generation));
1808
2762
  await this.outboxReady;
1809
2763
  }
2764
+ // Scope activation rejects once the replica is disposed (for example when
2765
+ // an auth error quarantines a client that was already closed). Callers that
2766
+ // await replicaReady still see the rejection; the stored promise itself must
2767
+ // not surface as an unhandled rejection that kills Node processes.
2768
+ setReplicaReady(ready) {
2769
+ this.replicaReady = ready;
2770
+ ready.catch(() => undefined);
2771
+ }
2772
+ // Outbox restore awaits replicaReady, so it rejects the same way.
2773
+ setOutboxReady(ready) {
2774
+ this.outboxReady = ready;
2775
+ ready.catch(() => undefined);
2776
+ }
1810
2777
  quarantineReplicaScope() {
2778
+ this.localIdentity = undefined;
1811
2779
  // Keep the durable prior identity scope intact for an authorized future
1812
2780
  // login, but make every synchronous selector fail closed immediately.
1813
2781
  // The random suffix prevents a denied scope from ever restoring rows.
@@ -1819,7 +2787,7 @@ export class GonvexClient {
1819
2787
  this.resetReplicaScopeState();
1820
2788
  this.replicaScope = scope;
1821
2789
  this.hasAuthoritativeReplicaScope = false;
1822
- this.replicaReady = this.replica.activateScope(scope, true);
2790
+ this.setReplicaReady(this.replica.activateScope(scope, true));
1823
2791
  this.rotateSubscriptionScopes();
1824
2792
  }
1825
2793
  rejectMissingReplicaDirective() {
@@ -1832,12 +2800,26 @@ export class GonvexClient {
1832
2800
  }
1833
2801
  async restoreOutbox(scope, generation) {
1834
2802
  await this.replicaReady;
2803
+ await this.replica.synchronizeStorage();
1835
2804
  const entries = await this.reducerOutbox.loadAll(scope);
1836
2805
  if (this.manuallyClosed
1837
2806
  || generation !== this.outboxScopeGeneration
1838
2807
  || scope !== this.outboxScope)
1839
2808
  return;
1840
2809
  for (const entry of entries) {
2810
+ // markCommitted follows the durable replica watermark barrier. Its
2811
+ // transaction is already in the restored base; never predict it twice.
2812
+ if (entry.localExecution && entry.state === "committed")
2813
+ await this.reducerOutbox.ack(entry.id);
2814
+ }
2815
+ if (entries.some((entry) => entry.localExecution) && this.localExecutor) {
2816
+ await this.inLocalLane(() => this.rebaseLocalEntries());
2817
+ }
2818
+ for (const entry of entries) {
2819
+ if (entry.localExecution && this.localExecutor)
2820
+ continue;
2821
+ if (!outboxEntryIsLive(entry))
2822
+ continue;
1841
2823
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
1842
2824
  await this.reducerOutbox.ack(entry.id);
1843
2825
  continue;
@@ -1854,11 +2836,15 @@ export class GonvexClient {
1854
2836
  // If this scope was installed after the socket authenticated, no reconnect
1855
2837
  // or new enqueue may occur to wake the queue. The await inside drainOutbox
1856
2838
  // yields until this restore promise resolves, then safely resumes it.
2839
+ this.refreshIntents();
1857
2840
  void this.drainOutbox();
1858
2841
  }
1859
2842
  addOptimisticReducer(reducerId, patches, accepted = false) {
1860
- if (patches.length === 0 || this.optimisticReducerIds.has(reducerId))
2843
+ if (patches.length === 0) {
2844
+ this.optimisticReducerIds.delete(reducerId);
2845
+ this.replica.rejectCommand(reducerId);
1861
2846
  return;
2847
+ }
1862
2848
  this.optimisticReducerIds.add(reducerId);
1863
2849
  this.replica.applyOptimistic(reducerId, patches);
1864
2850
  }
@@ -1878,69 +2864,195 @@ export class GonvexClient {
1878
2864
  await this.reducerOutbox.ack(entryId);
1879
2865
  }
1880
2866
  async drainOutbox() {
1881
- await this.outboxReady;
1882
- if (this.drainingOutbox
1883
- || this.manuallyClosed
1884
- || !this.canSendReducerNow())
2867
+ if (this.publicTenant)
2868
+ return;
2869
+ // A failed restore is reported to whoever awaits the scope transition;
2870
+ // there is nothing to drain from a scope that never became ready.
2871
+ try {
2872
+ await this.outboxReady;
2873
+ }
2874
+ catch {
2875
+ return;
2876
+ }
2877
+ if (this.drainingOutbox) {
2878
+ // The running drain may already be past the entry this wake-up is for
2879
+ // (for example a short backoff timer firing before the drain returned).
2880
+ this.outboxDrainRequested = true;
2881
+ return;
2882
+ }
2883
+ if (this.manuallyClosed || !this.canSendReducerNow())
1885
2884
  return;
1886
2885
  const drainScope = this.outboxScope;
1887
2886
  this.drainingOutbox = true;
2887
+ this.outboxDrainRequested = false;
1888
2888
  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);
2889
+ await this.coordinateOutbox("delivery", async () => {
2890
+ if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
1903
2891
  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)
1911
- 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);
2892
+ if (this.sharedOutboxStore)
2893
+ await this.reducerOutbox.recoverInflight?.(drainScope);
2894
+ while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
2895
+ const scope = this.outboxScope;
2896
+ const entry = await this.inLocalLane(async () => {
2897
+ const next = await this.reducerOutbox.nextReady(scope, Date.now());
2898
+ // A concurrent discard (this or another tab) fences the entry; a
2899
+ // refused transition means it must not be sent.
2900
+ if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
2901
+ return null;
2902
+ return next;
2903
+ });
2904
+ if (entry === null)
2905
+ continue;
2906
+ if (!entry) {
2907
+ await this.scheduleNextOutboxAttempt(scope);
2908
+ return;
1921
2909
  }
1922
- else {
1923
- await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2910
+ if (scope !== this.outboxScope)
2911
+ return;
2912
+ if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
2913
+ // Scope recovery may observe an inflight row created by this live
2914
+ // process and reset it to pending under the assumption that a prior
2915
+ // process crashed. The foreground call still owns that command ID.
2916
+ // Restore the durable marker and wait for its real result instead of
2917
+ // registering a second response handler for the same command.
2918
+ await this.reducerOutbox.markInflight(entry.id);
2919
+ return;
1924
2920
  }
1925
- }
1926
- catch (error) {
1927
- if (error instanceof GonvexClientError && error.code === "server") {
1928
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2921
+ if (!this.canSendReducerNow()) {
2922
+ await this.reducerOutbox.markPending(entry.id);
2923
+ return;
2924
+ }
2925
+ if ((await this.reducerOutbox.markInflight(entry.id)) === false)
1929
2926
  continue;
2927
+ if (scope !== this.outboxScope)
2928
+ return;
2929
+ if (!this.canSendReducerNow()) {
2930
+ await this.reducerOutbox.markPending(entry.id);
2931
+ return;
2932
+ }
2933
+ try {
2934
+ 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);
2935
+ this.unauthenticatedRetries = 0;
2936
+ await this.reducerOutbox.markCommitted(entry.id);
2937
+ if (entry.localExecution && this.localExecutor) {
2938
+ await this.inLocalLane(async () => {
2939
+ await this.reducerOutbox.ack(entry.id);
2940
+ if (scope === this.outboxScope)
2941
+ await this.rebaseLocalEntries();
2942
+ });
2943
+ }
2944
+ else if ((entry.patches?.length ?? 0) > 0) {
2945
+ await this.settleOptimisticReducer(entry.idempotencyKey);
2946
+ }
2947
+ else {
2948
+ await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2949
+ }
2950
+ }
2951
+ catch (error) {
2952
+ const disposition = deliveryErrorClass(error);
2953
+ if (disposition === "rejected") {
2954
+ // Keep today's rollback and rebase, but record the rejection
2955
+ // durably instead of deleting the intent without a trace.
2956
+ if (entry.localExecution && this.localExecutor) {
2957
+ await this.inLocalLane(async () => {
2958
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2959
+ if (scope === this.outboxScope) {
2960
+ await this.rebaseLocalEntries(true);
2961
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2962
+ }
2963
+ });
2964
+ }
2965
+ else {
2966
+ this.optimisticReducerIds.delete(entry.idempotencyKey);
2967
+ this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
2968
+ this.replica.rejectCommand(entry.idempotencyKey);
2969
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2970
+ if (scope === this.outboxScope)
2971
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2972
+ }
2973
+ continue;
2974
+ }
2975
+ const outcome = await this.recordDeliveryFailure(entry, error, disposition);
2976
+ // A parked intent keeps its prediction but no longer blocks the
2977
+ // chain; later intents proceed and the server validates them.
2978
+ if (outcome === "parked")
2979
+ continue;
2980
+ return;
1930
2981
  }
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
2982
  }
1935
- }
2983
+ });
1936
2984
  }
1937
2985
  finally {
1938
2986
  this.drainingOutbox = false;
1939
- if (!this.manuallyClosed && drainScope !== this.outboxScope) {
2987
+ this.scheduleLocalReplay();
2988
+ const requested = this.outboxDrainRequested;
2989
+ this.outboxDrainRequested = false;
2990
+ if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
1940
2991
  void this.drainOutbox();
1941
2992
  }
1942
2993
  }
1943
2994
  }
2995
+ /**
2996
+ * Record a non-rejection delivery failure and arm the next attempt.
2997
+ * - update_required: keep the intent pending and stop for an app update.
2998
+ * - unauthenticated: keep the intent, re-authenticate, retry with backoff
2999
+ * that never spends the retry budget.
3000
+ * - network: connectivity loss; retried on reconnect without spending budget.
3001
+ * - transient (and timeouts): exponential backoff, parked as `failed` once
3002
+ * the retry budget is spent.
3003
+ */
3004
+ async recordDeliveryFailure(entry, error, disposition) {
3005
+ const message = reducerErrorMessage(error);
3006
+ if (disposition === "update_required") {
3007
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
3008
+ this.requireClientUpdate(message);
3009
+ return "update_required";
3010
+ }
3011
+ if (disposition === "unauthenticated") {
3012
+ const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
3013
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
3014
+ this.requestReauthentication();
3015
+ this.scheduleOutboxDrain(delay);
3016
+ return "retrying";
3017
+ }
3018
+ const counted = disposition === "transient";
3019
+ const updated = await this.reducerOutbox.fail(entry.id, message, {
3020
+ errorClass: disposition,
3021
+ countAttempt: counted,
3022
+ maxAttempts: this.outboxRetry.maxAttempts,
3023
+ maxBackoffMs: this.outboxRetry.maxBackoffMs,
3024
+ ...(counted ? {} : { delayMs: 1_000 }),
3025
+ });
3026
+ if (updated && updated.state === "failed")
3027
+ return "parked";
3028
+ const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
3029
+ this.scheduleOutboxDrain(delay);
3030
+ return "retrying";
3031
+ }
3032
+ /**
3033
+ * Arm a timer for the earliest backed-off entry. Timer clocks and
3034
+ * `Date.now()` can disagree by a millisecond, so a wake-up may find its
3035
+ * entry not quite due; without this the queue would wait for an unrelated
3036
+ * event to resume.
3037
+ */
3038
+ async scheduleNextOutboxAttempt(scope) {
3039
+ const now = Date.now();
3040
+ let next = Infinity;
3041
+ for (const entry of await this.reducerOutbox.list(scope)) {
3042
+ if (entry.state === "pending" && entry.nextAttemptAt > now)
3043
+ next = Math.min(next, entry.nextAttemptAt);
3044
+ }
3045
+ if (Number.isFinite(next) && scope === this.outboxScope)
3046
+ this.scheduleOutboxDrain(next - now + 1);
3047
+ }
3048
+ /** Re-send auth on the open socket so a lost tenant session is restored. */
3049
+ requestReauthentication() {
3050
+ if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
3051
+ return;
3052
+ if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
3053
+ return;
3054
+ this.sendAuth(false);
3055
+ }
1944
3056
  scheduleOutboxDrain(delay) {
1945
3057
  if (this.manuallyClosed)
1946
3058
  return;
@@ -1952,6 +3064,13 @@ export class GonvexClient {
1952
3064
  }, delay);
1953
3065
  }
1954
3066
  reducer(ref, args = {}, options = {}) {
3067
+ if (this.publicTenant) {
3068
+ if (options.offline === "queue" || options.optimistic?.length)
3069
+ return Promise.reject(new GonvexClientError("Public reducers are server-only and cannot queue or execute optimistically", { code: "disconnected", path: ref.path, operation: "reducer" }));
3070
+ return this.call("reducer", ref, args, options.timeoutMs ?? this.timeouts.reducerTimeoutMs);
3071
+ }
3072
+ if (ref.localExecution === 1)
3073
+ return this.runLocalReducer(ref, args);
1955
3074
  if (options.offline === "queue" && ref.offline?.mode !== "allowed") {
1956
3075
  return Promise.reject(new GonvexClientError(`Reducer ${ref.path} does not allow offline queueing.`, { code: "disconnected", path: ref.path, operation: "reducer" }));
1957
3076
  }
@@ -1978,7 +3097,6 @@ export class GonvexClient {
1978
3097
  }
1979
3098
  this.directOutboxReducerIds.add(reducerId);
1980
3099
  let entryId;
1981
- let entryAttempts = 0;
1982
3100
  try {
1983
3101
  const scope = this.outboxScope;
1984
3102
  const entry = await this.reducerOutbox.enqueue({
@@ -1991,7 +3109,6 @@ export class GonvexClient {
1991
3109
  state: "inflight",
1992
3110
  });
1993
3111
  entryId = entry.id;
1994
- entryAttempts = entry.attempts;
1995
3112
  if (this.manuallyClosed) {
1996
3113
  await this.reducerOutbox.ack(entry.id);
1997
3114
  throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
@@ -2019,14 +3136,13 @@ export class GonvexClient {
2019
3136
  return result;
2020
3137
  }
2021
3138
  catch (error) {
2022
- if (isQueueableReducerError(error) && options.offline === "queue") {
3139
+ const disposition = deliveryErrorClass(error);
3140
+ if (disposition !== "rejected" && options.offline === "queue") {
2023
3141
  const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
2024
3142
  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))));
3143
+ // Records backoff (or parks the entry) and arms the client's timer,
3144
+ // exactly like the background drain path.
3145
+ await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
2030
3146
  }
2031
3147
  return { status: "queued", reducerId };
2032
3148
  }
@@ -2038,7 +3154,17 @@ export class GonvexClient {
2038
3154
  void this.drainOutbox();
2039
3155
  }
2040
3156
  }
3157
+ requireClientUpdate(reason) {
3158
+ if (this.updateRequired)
3159
+ return;
3160
+ this.updateRequired = true;
3161
+ // Keep durable intents intact. Closing suspends subscriptions and future sends.
3162
+ this.close();
3163
+ this.updateRequiredHandler?.(reason);
3164
+ }
2041
3165
  canSendReducerNow() {
3166
+ if (this.updateRequired)
3167
+ return false;
2042
3168
  if (globalThis.navigator?.onLine === false)
2043
3169
  return false;
2044
3170
  const socket = this.socket;
@@ -2183,6 +3309,10 @@ export class GonvexClient {
2183
3309
  ...(ref.scope === "control" ? { scope: "control" } : {}),
2184
3310
  trace: { clientSentAtMs: entry.clientSentAtMs },
2185
3311
  ...(effectiveIdempotencyKey ? { idempotencyKey: effectiveIdempotencyKey } : {}),
3312
+ ...(ref.artifactHash ? { artifactHash: ref.artifactHash } : {}),
3313
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
3314
+ ...(ref.receiptPath ? { receiptPath: ref.receiptPath } : {}),
3315
+ ...(ref.intentEntropy ? { intentEntropy: ref.intentEntropy } : {}),
2186
3316
  });
2187
3317
  }
2188
3318
  else {
@@ -2233,8 +3363,11 @@ export class GonvexClient {
2233
3363
  return;
2234
3364
  }
2235
3365
  if (kind === "reducer" && message.type === "reducer.result") {
2236
- this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2237
3366
  const complete = () => {
3367
+ // A collection frame can advance the shared cursor before another
3368
+ // collection's rows and membership have arrived. Retire the local
3369
+ // prediction only after the commit's durable watermark barrier.
3370
+ this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2238
3371
  settle();
2239
3372
  this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
2240
3373
  resolve(message.result);
@@ -2258,7 +3391,11 @@ export class GonvexClient {
2258
3391
  if (kind === "reducer" && message.type === "reducer.error") {
2259
3392
  settle();
2260
3393
  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 }));
3394
+ const errorClass = reducerErrorClassFromMessage(message);
3395
+ reject(new GonvexClientError(message.error, {
3396
+ code: "server", path: ref.path, operation: kind,
3397
+ ...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
3398
+ }));
2262
3399
  }
2263
3400
  if (kind === "action" && message.type === "action.result") {
2264
3401
  const complete = () => {
@@ -2485,6 +3622,9 @@ export class GonvexClient {
2485
3622
  this.send(message);
2486
3623
  }
2487
3624
  hasControlPlaneWork() {
3625
+ for (const query of this.querySubscriptions.values())
3626
+ if (query.executionScope === "control")
3627
+ return true;
2488
3628
  for (const query of this.oneShotQueries.values())
2489
3629
  if (query.scope === "control")
2490
3630
  return true;
@@ -2615,6 +3755,21 @@ export class GonvexClient {
2615
3755
  if (this.manuallyClosed)
2616
3756
  return Promise.reject(new Error("Gonvex client is closed"));
2617
3757
  this.connect();
3758
+ if (!this.activeAuthFrameId) {
3759
+ return new Promise((resolve, reject) => {
3760
+ this.pendingNativeErrors.push({ type, payload, resolve, reject });
3761
+ });
3762
+ }
3763
+ return this.sendNativeErrorFrame(type, payload);
3764
+ }
3765
+ flushPendingNativeErrors() {
3766
+ if (!this.activeAuthFrameId || this.pendingNativeErrors.length === 0)
3767
+ return;
3768
+ for (const pending of this.pendingNativeErrors.splice(0)) {
3769
+ void this.sendNativeErrorFrame(pending.type, pending.payload).then(pending.resolve, pending.reject);
3770
+ }
3771
+ }
3772
+ sendNativeErrorFrame(type, payload) {
2618
3773
  const id = randomID();
2619
3774
  return new Promise((resolve, reject) => {
2620
3775
  const timer = setTimeout(() => {
@@ -2663,11 +3818,13 @@ export class GonvexClient {
2663
3818
  this.managedAuthAttempt?.ids.add(id);
2664
3819
  this.sendNow({
2665
3820
  type: "auth",
3821
+ ...(this.clientContract ? { clientContract: this.clientContract.version } : {}),
2666
3822
  id,
2667
3823
  token: this.auth.token,
2668
3824
  project: this.auth.project,
2669
3825
  tenant: this.auth.tenant,
2670
3826
  controlOnly: !this.auth.tenant,
3827
+ ...(this.publicTenant ? { public: true } : {}),
2671
3828
  device: browserTelemetryInfo(),
2672
3829
  capabilities: { replicaReadyMany: 1, replicaWatermark: 1, queryPagePatch: 1, queryObjectPatch: 1, queryOrderDelta: 1, queryFanout: 1, queryResultBatch: 1 },
2673
3830
  });
@@ -2805,7 +3962,19 @@ export class GonvexClient {
2805
3962
  socket.send(JSON.stringify(message));
2806
3963
  return;
2807
3964
  }
2808
- this.send(message);
3965
+ // A replaced socket re-sends its own work; don't route this message
3966
+ // onto the newer connection a second time.
3967
+ if (this.socket !== socket)
3968
+ return;
3969
+ // Public control calls (such as the token exchange) renew the
3970
+ // session that the pending auth is waiting for. Queueing them behind
3971
+ // that auth deadlocks the connection, so keep their auth bypass.
3972
+ const id = "id" in message ? message.id : undefined;
3973
+ const pending = id ? this.pendingCalls.get(id) ?? this.oneShotQueries.get(id) : undefined;
3974
+ if ("scope" in message && message.scope === "control" && pending?.authorization === "public")
3975
+ this.sendNow(message);
3976
+ else
3977
+ this.send(message);
2809
3978
  }, { once: true });
2810
3979
  return;
2811
3980
  }
@@ -2860,15 +4029,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
2860
4029
  const current = result[head];
2861
4030
  return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
2862
4031
  }
4032
+ // Generated references and their plans are immutable module metadata. Retain
4033
+ // one canonical contract per reference, not a fresh sorted serialization for
4034
+ // every React watch/read. Weak keys do not retain replaced modules or callers.
4035
+ const subscriptionContracts = new WeakMap();
2863
4036
  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)}`;
4037
+ let cached = subscriptionContracts.get(ref);
4038
+ if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
4039
+ const contract = {
4040
+ scope: ref.scope ?? "tenant",
4041
+ delivery: ref.delivery ?? "oneShot",
4042
+ live: ref.live
4043
+ ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
4044
+ : null,
4045
+ };
4046
+ cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
4047
+ subscriptionContracts.set(ref, cached);
4048
+ }
4049
+ const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
4050
+ const previous = cached.keys.get(argumentsKey);
4051
+ if (previous !== undefined)
4052
+ return previous;
4053
+ const key = `${argumentsKey}\u0000${cached.value}`;
4054
+ // Search text and pagination may produce unbounded argument combinations.
4055
+ if (cached.keys.size >= 16)
4056
+ cached.keys.delete(cached.keys.keys().next().value);
4057
+ cached.keys.set(argumentsKey, key);
4058
+ return key;
2872
4059
  }
2873
4060
  function countPendingCalls(calls, kind) {
2874
4061
  let count = 0;
@@ -2878,9 +4065,96 @@ function countPendingCalls(calls, kind) {
2878
4065
  }
2879
4066
  return count;
2880
4067
  }
2881
- function isQueueableReducerError(error) {
2882
- return error instanceof GonvexClientError
2883
- && (error.code === "disconnected" || error.code === "timeout");
4068
+ /**
4069
+ * How the outbox treats one failed delivery. Runtimes before
4070
+ * 0.5.2-staging.15 send no class: their server errors stay rejections, as
4071
+ * before, except the two cases the legacy runtime could only express in prose.
4072
+ */
4073
+ function deliveryErrorClass(error) {
4074
+ if (!(error instanceof GonvexClientError))
4075
+ return "transient";
4076
+ switch (error.code) {
4077
+ case "server": return error.errorClass ?? "rejected";
4078
+ case "timeout": return "transient";
4079
+ default: return "network";
4080
+ }
4081
+ }
4082
+ const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
4083
+ function reducerErrorClassFromMessage(message) {
4084
+ if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
4085
+ return message.class;
4086
+ }
4087
+ // Legacy runtimes: the only structured signals were these literal messages.
4088
+ if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
4089
+ return "update_required";
4090
+ if (message.error === legacyUnauthenticatedReducerError)
4091
+ return "unauthenticated";
4092
+ return undefined;
4093
+ }
4094
+ function positiveOr(value, fallback) {
4095
+ return typeof value === "number" && value > 0 ? value : fallback;
4096
+ }
4097
+ function summarizeArgs(args) {
4098
+ let text;
4099
+ try {
4100
+ text = JSON.stringify(args) ?? "";
4101
+ }
4102
+ catch {
4103
+ text = String(args);
4104
+ }
4105
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
4106
+ }
4107
+ function outboxIntentFromEntry(entry) {
4108
+ const entities = new Map();
4109
+ for (const patch of entry.patches ?? []) {
4110
+ const entity = patch.entity ?? patch.collection;
4111
+ if (!entity)
4112
+ continue;
4113
+ entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
4114
+ }
4115
+ return {
4116
+ id: entry.idempotencyKey,
4117
+ entryId: entry.id,
4118
+ reducer: entry.path,
4119
+ state: entry.state,
4120
+ attempts: entry.attempts,
4121
+ ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
4122
+ ...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
4123
+ createdAt: entry.createdAt,
4124
+ nextAttemptAt: entry.nextAttemptAt,
4125
+ ...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
4126
+ args: entry.args,
4127
+ argsSummary: summarizeArgs(entry.args),
4128
+ entities: [...entities.values()],
4129
+ };
4130
+ }
4131
+ function sameIntents(left, right) {
4132
+ if (left.length !== right.length)
4133
+ return false;
4134
+ for (let index = 0; index < left.length; index += 1) {
4135
+ const a = left[index], b = right[index];
4136
+ if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
4137
+ || a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
4138
+ || a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
4139
+ || a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
4140
+ return false;
4141
+ }
4142
+ return true;
4143
+ }
4144
+ /** Row status from a list of intents: failed > rejected > syncing. */
4145
+ export function entityStatusFromIntents(intents, entity, id) {
4146
+ let status;
4147
+ for (const intent of intents) {
4148
+ if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
4149
+ continue;
4150
+ if (intent.state === "failed")
4151
+ return "failed";
4152
+ if (intent.state === "rejected")
4153
+ status = "rejected";
4154
+ else if (intent.state !== "committed" && status === undefined)
4155
+ status = "syncing";
4156
+ }
4157
+ return status;
2884
4158
  }
2885
4159
  function reducerErrorMessage(error) {
2886
4160
  return error instanceof Error ? error.message : String(error);
@@ -2902,6 +4176,9 @@ function stableStringify(value) {
2902
4176
  .join(",")}}`;
2903
4177
  }
2904
4178
  function utf8KeyCompare(left, right) {
4179
+ // ASCII keys have identical UTF-8 and JavaScript lexical ordering.
4180
+ if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
4181
+ return left < right ? -1 : left > right ? 1 : 0;
2905
4182
  const leftBytes = new TextEncoder().encode(left);
2906
4183
  const rightBytes = new TextEncoder().encode(right);
2907
4184
  const length = Math.min(leftBytes.length, rightBytes.length);
@@ -2922,9 +4199,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
2922
4199
  const key = replicaRowKeyValue(row, keyField);
2923
4200
  if (!key || seen.has(key))
2924
4201
  continue;
2925
- const size = replicaJSONSize(row);
2926
4202
  if (maxRows && kept.length >= maxRows)
2927
4203
  break;
4204
+ const size = maxBytes ? replicaJSONSize(row) : 0;
2928
4205
  if (maxBytes && bytes + size > maxBytes)
2929
4206
  break;
2930
4207
  kept.push(row);
@@ -2971,7 +4248,10 @@ function replicaRowKeyValue(value, keyField) {
2971
4248
  return key === null || key === undefined ? "" : String(key);
2972
4249
  }
2973
4250
  function replicaJSONSize(value) {
2974
- return new TextEncoder().encode(stableStringify(value)).byteLength;
4251
+ // Property ordering cannot change the encoded length. Native serialization
4252
+ // avoids recursively allocating sorted key arrays and intermediate strings.
4253
+ const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
4254
+ return new TextEncoder().encode(json).byteLength;
2975
4255
  }
2976
4256
  function applyKeyedPatch(previous, patch) {
2977
4257
  const rows = new Map();
@@ -3038,6 +4318,31 @@ function queryPatchRowKey(value) {
3038
4318
  const candidate = value._id ?? value.id;
3039
4319
  return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : "";
3040
4320
  }
4321
+ function applyLocalPatches(snapshot, patches) {
4322
+ for (const patch of patches) {
4323
+ const entity = patch.entity ?? patch.collection;
4324
+ if (!entity)
4325
+ continue;
4326
+ const table = snapshot.tables[entity] ??= { complete: false, rows: [] };
4327
+ const index = table.rows.findIndex((row) => String(row._id ?? row.id) === patch.rowId);
4328
+ if (patch.op === "delete") {
4329
+ if (index >= 0)
4330
+ table.rows.splice(index, 1);
4331
+ }
4332
+ else if (index >= 0)
4333
+ table.rows[index] = { ...table.rows[index], ...structuredClone(patch.fields) };
4334
+ else if (patch.op !== "patch")
4335
+ table.rows.push(structuredClone(patch.fields));
4336
+ }
4337
+ }
4338
+ function localIdentityFromAuthResult(result) {
4339
+ if (!result || typeof result !== "object" || Array.isArray(result))
4340
+ return;
4341
+ const identity = result.localIdentity;
4342
+ if (!identity?.auth?.account?.id || !identity.tenant?.id || !identity.member?.id)
4343
+ return;
4344
+ return structuredClone(identity);
4345
+ }
3041
4346
  function authFromOptions(options) {
3042
4347
  return {
3043
4348
  project: options.project,
@@ -3056,8 +4361,6 @@ function normalizeQuerySubscriptionRetentionMs(value) {
3056
4361
  return Math.max(0, Math.min(5 * 60_000, Math.floor(value)));
3057
4362
  }
3058
4363
  function authIdentityKey(auth) {
3059
- if (!auth.tenant)
3060
- return "";
3061
4364
  if (auth.token) {
3062
4365
  const tokenIdentity = authIdentityKeyFromToken(auth);
3063
4366
  if (tokenIdentity)
@@ -3067,12 +4370,12 @@ function authIdentityKey(auth) {
3067
4370
  // token would supply, so both paths derive the same key for the same Account.
3068
4371
  const hint = auth.identity;
3069
4372
  if (hint && typeof hint.sub === "string" && hint.sub.trim()) {
3070
- return [auth.project ?? "", auth.tenant, hint.iss ?? "", hint.sub].join("\u0000");
4373
+ return [auth.project ?? "", auth.tenant ?? "", hint.iss ?? "", hint.sub].join("\u0000");
3071
4374
  }
3072
4375
  return "";
3073
4376
  }
3074
4377
  function authIdentityKeyFromToken(auth) {
3075
- if (!auth.token || !auth.tenant)
4378
+ if (!auth.token)
3076
4379
  return "";
3077
4380
  const parts = auth.token.split(".");
3078
4381
  if (parts.length < 2)
@@ -3085,7 +4388,7 @@ function authIdentityKeyFromToken(auth) {
3085
4388
  return "";
3086
4389
  return [
3087
4390
  auth.project ?? "",
3088
- auth.tenant,
4391
+ auth.tenant ?? "",
3089
4392
  typeof payload.iss === "string" ? payload.iss : "",
3090
4393
  payload.sub,
3091
4394
  ].join("\u0000");
@@ -3212,4 +4515,56 @@ function detectDeviceType(userAgent) {
3212
4515
  return "mobile";
3213
4516
  return "desktop";
3214
4517
  }
4518
+ /**
4519
+ * Dependency tables of locally executing Reducers that stay resident. A
4520
+ * table qualifies when its unfiltered generated collection declares a small
4521
+ * enough `maxRows` (and `maxBytes`, when bounded). Collections without a
4522
+ * declared bound are never resident: their size is unknown.
4523
+ */
4524
+ export function residentLocalDependencyTables(binding, policy = "bounded") {
4525
+ if (!binding?.localDependencies || policy === "off")
4526
+ return [];
4527
+ const bounds = policy === "bounded" ? {} : policy;
4528
+ const maxRows = bounds.maxRows ?? DEFAULT_LOCAL_DEPENDENCY_RESIDENT_MAX_ROWS;
4529
+ const maxBytes = bounds.maxBytes;
4530
+ const tables = new Set(Object.values(binding.localDependencies).flat());
4531
+ return [...tables].filter((table) => (binding.collections ?? []).some((reference) => {
4532
+ const replica = reference.replica;
4533
+ if (replica?.table !== table || Object.keys(replica.equalFilters ?? {}).length || replica.excludeWhenSet?.length)
4534
+ return false;
4535
+ if (!replica.maxRows || replica.maxRows > maxRows)
4536
+ return false;
4537
+ return maxBytes === undefined || (!!replica.maxBytes && replica.maxBytes <= maxBytes);
4538
+ })).sort();
4539
+ }
4540
+ /** Sign-in and account-only pages do not need reducer code loaded. */
4541
+ function deferredLocalExecutor(create) {
4542
+ let executor;
4543
+ let closed = false;
4544
+ const start = () => {
4545
+ if (closed)
4546
+ throw new Error("Local reducer executor is closed");
4547
+ return executor ??= create();
4548
+ };
4549
+ return {
4550
+ get ready() {
4551
+ try {
4552
+ return start().ready;
4553
+ }
4554
+ catch (error) {
4555
+ return Promise.reject(error);
4556
+ }
4557
+ },
4558
+ prepare: async (path) => start().prepare?.(path),
4559
+ execute: async (...args) => start().execute(...args),
4560
+ executeRead: async (...args) => {
4561
+ const instance = start();
4562
+ if (!instance.executeRead)
4563
+ throw new Error('Local executor does not support indexed reads');
4564
+ return instance.executeRead(...args);
4565
+ },
4566
+ replay: async (...args) => start().replay(...args),
4567
+ close: () => { closed = true; executor?.close(); },
4568
+ };
4569
+ }
3215
4570
  //# sourceMappingURL=index.js.map