@gonvex/client 0.5.2-staging.13 → 0.5.2-staging.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
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";
@@ -12,6 +13,8 @@ export * from "./kv-stores.js";
12
13
  export * from "./client-upgrades.js";
13
14
  export * from "./browser-upgrades.js";
14
15
  export * from "./signals.js";
16
+ export * from "./paged-read-view.js";
17
+ export { mergeReplicaRecord } from './replica-record.js';
15
18
  export * from "./external-auth.js";
16
19
  // Keep the mutable LocalReplica implementation private to GonvexClient. The
17
20
  // public package exposes only the read-only view plus storage/value types.
@@ -41,6 +44,7 @@ function createLocalReplicaView(replica) {
41
44
  cursor: () => replica.cursor(),
42
45
  freshness: () => replica.freshness(),
43
46
  version: () => replica.version(),
47
+ entityVersion: (entity, id) => replica.entityVersion(entity, id),
44
48
  subscribe: (listener) => replica.subscribe(listener),
45
49
  hasPendingCommand: (commandId) => replica.hasPendingCommand(commandId),
46
50
  getWindow: (signature) => replica.getWindow(signature),
@@ -51,6 +55,7 @@ function createLocalReplicaView(replica) {
51
55
  entityRows: (entity) => replica.entityRows(entity),
52
56
  entityCompleteness: (entity) => replica.entityCompleteness(entity),
53
57
  liveQuery: (signature) => replica.liveQuery(signature),
58
+ liveQuerySnapshot: (signature) => replica.liveQuerySnapshot(signature),
54
59
  collectionState: (signature) => replica.collectionState(signature),
55
60
  hasLiveQuery: (signature) => replica.hasLiveQuery(signature),
56
61
  snapshot: () => replica.snapshot(),
@@ -91,14 +96,28 @@ export class GonvexClientError extends Error {
91
96
  code;
92
97
  path;
93
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;
94
107
  constructor(message, options) {
95
108
  super(message);
96
109
  this.name = "GonvexClientError";
97
110
  this.code = options.code;
98
111
  this.path = options.path;
99
112
  this.operation = options.operation;
113
+ if (options.errorClass)
114
+ this.errorClass = options.errorClass;
115
+ if (options.retryable !== undefined)
116
+ this.retryable = options.retryable;
100
117
  }
101
118
  }
119
+ export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
120
+ export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
102
121
  export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
103
122
  export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
104
123
  export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
@@ -117,20 +136,32 @@ const maxReplicaBatchOpens = 256;
117
136
  // normally settle in a few milliseconds.
118
137
  export class GonvexClient {
119
138
  url;
139
+ localTableBindings = new Map();
120
140
  localBinding;
121
141
  localExecutor;
122
142
  localStorage;
123
143
  localIdentity;
124
144
  localLane = Promise.resolve();
145
+ preparationLane = Promise.resolve();
125
146
  localReplayScheduled = false;
147
+ waitingLocalEdits = 0;
126
148
  replacingLocal = false;
127
149
  unsubscribeLocal;
128
150
  localCollectionClosers = [];
151
+ localCollectionKeys = new Set();
152
+ localExecutionTables = new Map();
129
153
  reducerRejectionHandlers = new Set();
154
+ outboxRetry;
155
+ unauthenticatedRetries = 0;
156
+ intentsSnapshotValue = [];
157
+ intentListeners = new Set();
158
+ intentsRefreshRunning = false;
159
+ intentsRefreshDirty = false;
130
160
  socket;
131
161
  handlers = new Map();
132
162
  querySubscriptions = new Map();
133
163
  replicaSubscriptions = new Map();
164
+ replicaMetadataListeners = new WeakSet();
134
165
  oneShotQueries = new Map();
135
166
  telemetryHandlers = new Set();
136
167
  pendingMessages = [];
@@ -179,8 +210,15 @@ export class GonvexClient {
179
210
  processedReplicaWatermarkRevision = 0;
180
211
  pendingReplicaTransactions = [];
181
212
  unsubscribeOutbox;
213
+ sharedOutboxStore;
214
+ unsubscribePeerOutbox;
215
+ unsubscribePeerReplica;
216
+ peerRefreshScheduled = false;
217
+ peerRefreshDirty = false;
182
218
  unsubscribeBrowserOnline;
183
219
  drainingOutbox = false;
220
+ /** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
221
+ outboxDrainRequested = false;
184
222
  outboxDrainTimer;
185
223
  sessionScopeHandlers = new Set();
186
224
  errorReporter;
@@ -206,18 +244,43 @@ export class GonvexClient {
206
244
  this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
207
245
  this.updateRequiredHandler = options.onUpdateRequired;
208
246
  this.localBinding = options.localRuntime;
209
- this.localExecutor = options.localRuntime?.create();
247
+ for (const table of this.localBinding?.tables ?? []) {
248
+ const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
249
+ && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
250
+ this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
251
+ columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
252
+ }
253
+ this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
210
254
  this.localStorage = options.localReplica?.storage;
211
255
  this.auth = authFromOptions(options);
212
256
  this.telemetryEnabled = options.telemetry === true;
213
257
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
214
258
  this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
259
+ this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
215
260
  this.reducerOutbox = createReducerOutbox(options.outbox);
216
- this.replica = new LocalReplica(options.localReplica?.storage);
261
+ this.outboxRetry = {
262
+ maxAttempts: positiveOr(options.outbox?.retry?.maxAttempts, DEFAULT_OUTBOX_MAX_ATTEMPTS),
263
+ maxBackoffMs: positiveOr(options.outbox?.retry?.maxBackoffMs, DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS),
264
+ };
265
+ this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
217
266
  this.replicaView = createLocalReplicaView(this.replica);
218
- if (this.localExecutor)
219
- this.unsubscribeLocal = this.replica.subscribe(() => this.scheduleLocalReplay());
267
+ if (this.localExecutor) {
268
+ let executionVersion = this.replica.executionVersion();
269
+ this.unsubscribeLocal = this.replica.subscribe(() => {
270
+ const next = this.replica.executionVersion();
271
+ if (next === executionVersion)
272
+ return;
273
+ executionVersion = next;
274
+ this.scheduleLocalReplay();
275
+ });
276
+ }
277
+ this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
278
+ this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
279
+ if (scope === this.replicaScope)
280
+ this.refreshPeerOutbox();
281
+ });
220
282
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
283
+ this.refreshIntents();
221
284
  void this.drainOutbox();
222
285
  });
223
286
  if (typeof globalThis.addEventListener === "function") {
@@ -256,11 +319,254 @@ export class GonvexClient {
256
319
  if (this.localBinding)
257
320
  this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
258
321
  }
259
- /** Authoritative sync failures arrive after an offline call returned locally. */
322
+ /**
323
+ * Authoritative sync failures arrive after an offline call returned locally.
324
+ * The intent also stays in the outbox as `rejected` until it is discarded
325
+ * or retried, so a UI that mounts later can still show it.
326
+ */
260
327
  onReducerRejection(listener) {
261
328
  this.reducerRejectionHandlers.add(listener);
262
329
  return () => this.reducerRejectionHandlers.delete(listener);
263
330
  }
331
+ /** Every durable intent of the current identity, oldest first. */
332
+ async listIntents() {
333
+ await this.outboxReady;
334
+ return (await this.reducerOutbox.list(this.outboxScope)).map(outboxIntentFromEntry);
335
+ }
336
+ /**
337
+ * Synchronous snapshot for external stores (React `useSyncExternalStore`).
338
+ * Kept current while at least one {@link subscribeIntents} listener exists.
339
+ */
340
+ intentsSnapshot() {
341
+ return this.intentsSnapshotValue;
342
+ }
343
+ /** Observe intent changes; call {@link intentsSnapshot} for the new value. */
344
+ subscribeIntents(listener) {
345
+ this.intentListeners.add(listener);
346
+ if (this.intentListeners.size === 1)
347
+ this.refreshIntents();
348
+ return () => { this.intentListeners.delete(listener); };
349
+ }
350
+ /**
351
+ * Delivery status for one row from the current snapshot: `failed` or
352
+ * `rejected` when an intent touching it needs attention, `syncing` while one
353
+ * is still queued, otherwise undefined.
354
+ */
355
+ entityIntentStatus(entity, id) {
356
+ return entityStatusFromIntents(this.intentsSnapshotValue, entity, id);
357
+ }
358
+ /**
359
+ * Re-arm a `failed` or `rejected` intent with a fresh retry budget. The
360
+ * original idempotency key is reused, so an attempt that had actually
361
+ * committed is replayed by the server rather than applied twice.
362
+ */
363
+ async retryIntent(id) {
364
+ await this.outboxReady;
365
+ const retried = await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
366
+ const scope = this.outboxScope;
367
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
368
+ if (!entry || scope !== this.outboxScope)
369
+ return false;
370
+ const updated = await this.reducerOutbox.retry(entry.id);
371
+ if (!updated)
372
+ return false;
373
+ this.optimisticOutboxEntryIds.set(id, updated.id);
374
+ if (!outboxEntryIsLive(entry)) {
375
+ // A rejection rolled the prediction back; restore it at its original
376
+ // position in the causal chain.
377
+ if (updated.localExecution && this.localExecutor)
378
+ await this.rebaseLocalEntriesLocked(true);
379
+ else
380
+ this.addOptimisticReducer(id, updated.patches ?? []);
381
+ }
382
+ return true;
383
+ }));
384
+ if (retried) {
385
+ this.unauthenticatedRetries = 0;
386
+ void this.drainOutbox();
387
+ }
388
+ return retried;
389
+ }
390
+ /**
391
+ * Drop a `pending`, `failed` or `rejected` intent: it will never be sent,
392
+ * its optimistic prediction is removed and later local intents are rebased.
393
+ * Inflight and committed intents cannot be discarded. Discarding an intent
394
+ * whose earlier attempt timed out does not undo a commit that the server
395
+ * may already have applied; the replica then shows the server's truth.
396
+ */
397
+ async discardIntent(id) {
398
+ await this.outboxReady;
399
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
400
+ const scope = this.outboxScope;
401
+ const entry = (await this.reducerOutbox.list(scope)).find((candidate) => candidate.idempotencyKey === id);
402
+ if (!entry || scope !== this.outboxScope)
403
+ return false;
404
+ const removed = await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]);
405
+ if (!removed)
406
+ return false;
407
+ this.optimisticOutboxEntryIds.delete(id);
408
+ this.optimisticReducerIds.delete(id);
409
+ if (outboxEntryIsLive(removed)) {
410
+ this.replacingLocal = true;
411
+ try {
412
+ this.replica.rejectCommand(id);
413
+ }
414
+ finally {
415
+ this.replacingLocal = false;
416
+ }
417
+ if (removed.localExecution && this.localExecutor)
418
+ await this.rebaseLocalEntriesLocked(true);
419
+ }
420
+ return true;
421
+ }));
422
+ }
423
+ /**
424
+ * Discard the persisted Local Replica of the active identity (IndexedDB,
425
+ * Expo SQLite or any configured storage) and rehydrate it from the server.
426
+ *
427
+ * Online only: without an authenticated connection the call rejects with a
428
+ * `GonvexClientError` (`code: "disconnected"`) and changes nothing, so the
429
+ * user is never left with an empty cache that cannot be refilled. The saved
430
+ * offline session (identity and replica directive) is kept.
431
+ *
432
+ * By default the outbox is untouched: pending, failed and rejected intents
433
+ * stay durable, and the predictions of every live intent are re-applied
434
+ * immediately and recomputed as server data arrives. Active Replica
435
+ * Collections and Live Queries are re-requested without their old cursors,
436
+ * so the server sends full snapshots.
437
+ */
438
+ async resetLocalReplica(options = {}) {
439
+ const refuse = (message) => new GonvexClientError(message, { code: "disconnected", operation: "query" });
440
+ if (this.manuallyClosed)
441
+ throw new GonvexClientError("Gonvex client is closed", { code: "closed", operation: "query" });
442
+ await this.outboxReady;
443
+ await this.replicaReady;
444
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
445
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
446
+ }
447
+ const keepOutbox = options.keepOutbox !== false;
448
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
449
+ const scope = this.outboxScope;
450
+ const replicaScope = this.replicaScope;
451
+ // Re-check under the lock: the connection may have dropped while waiting.
452
+ if (!this.canSendReducerNow() || !this.hasAuthoritativeReplicaScope) {
453
+ throw refuse("Cannot reset local data while offline. Reconnect and try again; cached data was kept.");
454
+ }
455
+ // Stop the server streaming into subscription ids we are about to retire.
456
+ for (const subscription of this.replicaSubscriptions.values()) {
457
+ if (subscription.socketGeneration !== undefined)
458
+ this.send({ type: "replica.close", id: subscription.id });
459
+ }
460
+ for (const subscription of this.querySubscriptions.values()) {
461
+ if (subscription.socketGeneration !== undefined)
462
+ this.send({ type: "query.unsubscribe", id: subscription.id });
463
+ }
464
+ this.pendingReplicaTransactions.length = 0;
465
+ await this.replica.clear(replicaScope);
466
+ this.resetReplicaScopeState();
467
+ this.rotateSubscriptionScopes();
468
+ let discardedIntents = 0;
469
+ if (!keepOutbox) {
470
+ for (const entry of await this.reducerOutbox.list(scope)) {
471
+ if (await this.reducerOutbox.discard(entry.id, ["pending", "failed", "rejected"]))
472
+ discardedIntents += 1;
473
+ }
474
+ }
475
+ // replica.clear() dropped every overlay. Restore the stored predictions
476
+ // of live intents now; local intents are re-executed by the replay that
477
+ // each arriving snapshot schedules.
478
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
479
+ this.replacingLocal = true;
480
+ try {
481
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
482
+ }
483
+ finally {
484
+ this.replacingLocal = false;
485
+ }
486
+ this.optimisticReducerIds.clear();
487
+ this.optimisticOutboxEntryIds.clear();
488
+ for (const entry of entries) {
489
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
490
+ if (entry.state !== "committed" && entry.patches?.length)
491
+ this.optimisticReducerIds.add(entry.idempotencyKey);
492
+ }
493
+ let resubscribed = 0;
494
+ if (scope === this.outboxScope && replicaScope === this.replicaScope) {
495
+ for (const subscription of this.replicaSubscriptions.values())
496
+ if (subscription.listeners.size > 0)
497
+ resubscribed += 1;
498
+ for (const subscription of this.querySubscriptions.values())
499
+ if (subscription.listeners.size > 0)
500
+ resubscribed += 1;
501
+ this.resumeQuerySubscriptions(true);
502
+ this.resumeReplicaSubscriptions();
503
+ }
504
+ this.refreshIntents();
505
+ return { discardedIntents, resubscribed };
506
+ }));
507
+ }
508
+ /** Outbox owners with durable entries, including identities that never returned. */
509
+ async listOutboxScopes() {
510
+ await this.outboxReady;
511
+ const current = this.outboxScope;
512
+ return (await this.reducerOutbox.listScopes()).map((scope) => ({ ...scope, current: scope.scope === current }));
513
+ }
514
+ /**
515
+ * Permanently delete another identity's durable intents. The active scope
516
+ * cannot be purged this way; use {@link discardIntent} for its entries.
517
+ */
518
+ async purgeOutboxScope(scope) {
519
+ await this.outboxReady;
520
+ if (scope === this.outboxScope)
521
+ throw new Error("Cannot purge the active outbox scope");
522
+ return this.reducerOutbox.purgeScope(scope);
523
+ }
524
+ /** Purge every outbox scope except the current identity's. Never runs automatically. */
525
+ async purgeForeignOutboxScopes() {
526
+ let removed = 0;
527
+ for (const scope of await this.listOutboxScopes()) {
528
+ if (!scope.current)
529
+ removed += await this.purgeOutboxScope(scope.scope);
530
+ }
531
+ return removed;
532
+ }
533
+ refreshIntents() {
534
+ if (this.intentListeners.size === 0 || this.manuallyClosed)
535
+ return;
536
+ this.intentsRefreshDirty = true;
537
+ if (this.intentsRefreshRunning)
538
+ return;
539
+ this.intentsRefreshRunning = true;
540
+ void (async () => {
541
+ try {
542
+ while (this.intentsRefreshDirty && !this.manuallyClosed) {
543
+ this.intentsRefreshDirty = false;
544
+ await this.outboxReady.catch(() => undefined);
545
+ const scope = this.outboxScope;
546
+ const entries = await this.reducerOutbox.list(scope).catch(() => undefined);
547
+ if (!entries || scope !== this.outboxScope) {
548
+ this.intentsRefreshDirty ||= scope !== this.outboxScope;
549
+ continue;
550
+ }
551
+ this.publishIntents(entries.map(outboxIntentFromEntry));
552
+ }
553
+ }
554
+ finally {
555
+ this.intentsRefreshRunning = false;
556
+ }
557
+ })();
558
+ }
559
+ publishIntents(next) {
560
+ if (sameIntents(this.intentsSnapshotValue, next))
561
+ return;
562
+ this.intentsSnapshotValue = next;
563
+ for (const listener of this.intentListeners) {
564
+ try {
565
+ listener();
566
+ }
567
+ catch { /* A listener must not break delivery. */ }
568
+ }
569
+ }
264
570
  async restoreLocalSession(scope, generation) {
265
571
  if (!this.localBinding || !scope.startsWith("identity\u0000"))
266
572
  return;
@@ -269,38 +575,163 @@ export class GonvexClient {
269
575
  return;
270
576
  this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
271
577
  this.localIdentity = saved.identity;
578
+ void this.localExecutor?.ready.catch(() => undefined);
272
579
  await this.activateReplicaDirective(saved.directive);
273
580
  this.replica.setFreshness("offline");
274
581
  }
275
- ensureLocalCollections() {
276
- if (!this.localBinding || this.localCollectionClosers.length)
582
+ ensureLocalCollections(tables) {
583
+ if (!this.localBinding)
277
584
  return;
585
+ const needed = new Set(tables);
278
586
  for (const reference of this.localBinding.collections ?? []) {
279
- this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined));
587
+ if (!reference.replica || !needed.has(reference.replica.table))
588
+ continue;
589
+ const key = querySubscriptionKey(reference, {});
590
+ if (this.localCollectionKeys.has(key))
591
+ continue;
592
+ this.localCollectionKeys.add(key);
593
+ this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
280
594
  }
281
595
  }
596
+ hydrateMissingLocalTable(error) {
597
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
598
+ return;
599
+ // Worker errors preserve the public runtime name/message contract.
600
+ const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
601
+ if (table)
602
+ this.ensureLocalCollections([table]);
603
+ }
282
604
  localSnapshot(includePending) {
283
- const snapshot = this.replica.snapshot();
605
+ const rows = this.replica.captureExecutionRows(includePending);
284
606
  const tables = {};
285
- for (const table of this.localBinding?.tables ?? []) {
286
- const references = (this.localBinding?.collections ?? []).filter((ref) => ref.replica?.table === table
287
- && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
288
- const complete = references.some((ref) => {
289
- const window = snapshot.liveQueries[querySubscriptionKey(ref, {})];
290
- return window?.completeness === "complete" && window.truncated !== true;
291
- });
292
- tables[table] = { complete, columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))], rows: includePending ? this.replica.entityRows(table) : Object.values(snapshot.entities[table] ?? {}) };
607
+ for (const [table, binding] of this.localTableBindings) {
608
+ const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
609
+ tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
293
610
  }
294
611
  return { scope: this.replicaScope, tables };
295
612
  }
613
+ localReadCoverage() {
614
+ return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
615
+ key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
616
+ complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
617
+ }])));
618
+ }
619
+ isUncoveredLocalRead(error) {
620
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
621
+ return false;
622
+ const read = error.read;
623
+ if (!read || read.limit === 0)
624
+ return false;
625
+ const known = this.localReadCoverage()[read.table];
626
+ // Disk can recover evicted primary keys or a complete registered slice.
627
+ // It cannot prove a scan over data that has never been delivered. Queue
628
+ // that intent for replay instead of waiting for unrelated cache writes.
629
+ return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
630
+ }
631
+ async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
632
+ if (this.localBinding?.mode === 'portable') {
633
+ try {
634
+ const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
635
+ let transaction;
636
+ try {
637
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
638
+ }
639
+ catch (error) {
640
+ if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
641
+ throw error;
642
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
643
+ }
644
+ // The read view already proved every read from the available replica
645
+ // windows or cached primary keys. Do not promote those bounded reads
646
+ // to whole-table subscriptions after a successful user edit. Missing
647
+ // coverage still opens its generated collection in the error path.
648
+ return transaction;
649
+ }
650
+ catch (error) {
651
+ // A speculative memory-only read may simply need IndexedDB. Its
652
+ // coordinated retry checks disk before deciding to request more data.
653
+ if (!residentOnly)
654
+ this.hydrateMissingLocalTable(error);
655
+ throw error;
656
+ }
657
+ }
658
+ if (!snapshot)
659
+ throw new Error('Legacy local executor requires a snapshot');
660
+ const known = this.localExecutionTables.get(path);
661
+ const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
662
+ let transaction;
663
+ try {
664
+ transaction = await this.localExecutor.execute(path, args, input, execution);
665
+ }
666
+ catch (error) {
667
+ this.hydrateMissingLocalTable(error);
668
+ if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
669
+ throw error;
670
+ // A different argument, tenant or workflow can read different tables.
671
+ // Retry against the SAME captured snapshot, never newer mixed-version rows.
672
+ try {
673
+ transaction = await this.localExecutor.execute(path, args, snapshot, execution);
674
+ }
675
+ catch (retryError) {
676
+ this.hydrateMissingLocalTable(retryError);
677
+ throw retryError;
678
+ }
679
+ }
680
+ this.ensureLocalCollections(transaction.readTables);
681
+ this.localExecutionTables.set(path, new Set([
682
+ ...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
683
+ ]));
684
+ return transaction;
685
+ }
296
686
  inLocalLane(run) {
297
687
  const job = this.localLane.then(run);
298
688
  this.localLane = job.catch(() => undefined);
299
689
  return job;
300
690
  }
691
+ replaceLocalPredictions(commands) {
692
+ this.replica.replaceOptimistic(commands);
693
+ }
694
+ coordinateOutbox(lane, run) {
695
+ return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
696
+ }
697
+ refreshPeerOutbox() {
698
+ if (this.manuallyClosed)
699
+ return;
700
+ this.peerRefreshDirty = true;
701
+ if (this.peerRefreshScheduled)
702
+ return;
703
+ this.peerRefreshScheduled = true;
704
+ void (async () => {
705
+ await this.outboxReady;
706
+ do {
707
+ this.peerRefreshDirty = false;
708
+ await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
709
+ const scope = this.outboxScope;
710
+ await this.replica.synchronizeStorage();
711
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
712
+ if (this.manuallyClosed || scope !== this.outboxScope)
713
+ return;
714
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
715
+ this.optimisticReducerIds.clear();
716
+ this.optimisticOutboxEntryIds.clear();
717
+ for (const entry of entries) {
718
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
719
+ if (entry.patches?.length)
720
+ this.optimisticReducerIds.add(entry.idempotencyKey);
721
+ }
722
+ }));
723
+ } while (this.peerRefreshDirty && !this.manuallyClosed);
724
+ this.refreshIntents();
725
+ void this.drainOutbox();
726
+ })().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
727
+ }
301
728
  scheduleLocalReplay() {
302
729
  if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
303
730
  return;
731
+ // Restore and peer notifications discover durable intents explicitly. A
732
+ // table arriving at startup cannot invalidate a prediction that does not exist.
733
+ if (this.optimisticOutboxEntryIds.size === 0)
734
+ return;
304
735
  this.localReplayScheduled = true;
305
736
  queueMicrotask(() => {
306
737
  void (async () => {
@@ -311,24 +742,61 @@ export class GonvexClient {
311
742
  }
312
743
  reportLocalRejection(reducerId, path, error) {
313
744
  const message = reducerErrorMessage(error);
314
- for (const listener of this.reducerRejectionHandlers)
315
- listener({ reducerId, path, error: message });
745
+ for (const listener of this.reducerRejectionHandlers) {
746
+ try {
747
+ listener({ reducerId, path, error: message, errorClass: "rejected" });
748
+ }
749
+ catch { /* A listener must not break delivery. */ }
750
+ }
751
+ }
752
+ rebaseLocalEntries(afterRejection = false) {
753
+ return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
316
754
  }
317
- async rebaseLocalEntries() {
755
+ async rebaseLocalEntriesLocked(afterRejection = false) {
318
756
  if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
319
757
  return;
758
+ // The authoritative drain validates the pending chain in order. Keep its
759
+ // predictions while it progresses, then rebase once it settles or loses
760
+ // connectivity instead of replaying N + (N-1) + ... local transactions.
761
+ if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
762
+ return;
763
+ await this.replica.synchronizeStorage();
320
764
  const scope = this.outboxScope;
321
765
  const replicaScope = this.replicaScope;
322
- const entries = await this.reducerOutbox.list(scope);
323
- const baseVersion = this.replica.version();
324
- const snapshot = this.localSnapshot(false);
766
+ // Rejected records keep their payload for the UI but are never predicted.
767
+ // Failed (parked) intents stay predicted until retried or discarded.
768
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
769
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
770
+ return;
771
+ if (entries.length === 0) {
772
+ this.replacingLocal = true;
773
+ try {
774
+ this.replaceLocalPredictions([]);
775
+ }
776
+ finally {
777
+ this.replacingLocal = false;
778
+ }
779
+ this.optimisticReducerIds.clear();
780
+ this.optimisticOutboxEntryIds.clear();
781
+ return;
782
+ }
783
+ const baseVersion = this.replica.executionVersion();
784
+ const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
785
+ const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
786
+ const replayPatches = [];
325
787
  const commands = [];
326
788
  for (const entry of entries) {
789
+ // Predictions can wait; a fresh user intent must not sit behind the
790
+ // entire durable backlog. Publish only a complete, coherent rebase.
791
+ if (this.waitingLocalEdits > 0) {
792
+ setTimeout(() => this.scheduleLocalReplay(), 0);
793
+ return;
794
+ }
327
795
  let patches = entry.patches ?? [];
328
796
  if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
329
797
  const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
330
798
  try {
331
- const transaction = await this.localExecutor.execute(entry.path, entry.args, snapshot, execution);
799
+ const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
332
800
  patches = transaction.patches;
333
801
  if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
334
802
  return;
@@ -344,16 +812,25 @@ export class GonvexClient {
344
812
  // Storage failures must abort rebasing, never masquerade as rejection.
345
813
  await this.reducerOutbox.updateLocal(entry.id, patches, execution);
346
814
  }
347
- applyLocalPatches(snapshot, patches);
815
+ if (snapshot)
816
+ applyLocalPatches(snapshot, patches);
817
+ for (const patch of patches)
818
+ replayPatches.push(patch.op === 'delete'
819
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
820
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
348
821
  commands.push({ commandId: entry.idempotencyKey, patches });
349
822
  }
350
823
  if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
351
824
  return;
352
- if (baseVersion !== this.replica.version())
353
- return this.rebaseLocalEntries();
825
+ if (baseVersion !== this.replica.executionVersion()) {
826
+ // A changing server base must not recursively occupy the local lane.
827
+ // Release it so user intents already queued can run before the retry.
828
+ setTimeout(() => this.scheduleLocalReplay(), 0);
829
+ return;
830
+ }
354
831
  this.replacingLocal = true;
355
832
  try {
356
- this.replica.replaceOptimistic(commands);
833
+ this.replaceLocalPredictions(commands);
357
834
  }
358
835
  finally {
359
836
  this.replacingLocal = false;
@@ -362,9 +839,10 @@ export class GonvexClient {
362
839
  this.optimisticOutboxEntryIds.clear();
363
840
  for (const entry of entries) {
364
841
  this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
365
- if (commands.find((command) => command.commandId === entry.idempotencyKey)?.patches.length)
366
- this.optimisticReducerIds.add(entry.idempotencyKey);
367
842
  }
843
+ for (const command of commands)
844
+ if (command.patches.length)
845
+ this.optimisticReducerIds.add(command.commandId);
368
846
  }
369
847
  async runLocalReducer(ref, args) {
370
848
  args = structuredClone(args);
@@ -373,7 +851,38 @@ export class GonvexClient {
373
851
  await this.replicaReady;
374
852
  if (!this.localExecutor || !this.localBinding)
375
853
  throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
376
- return this.inLocalLane(async () => {
854
+ const reducerId = randomID();
855
+ const execution = this.localIdentity ? {
856
+ scope: this.replicaScope, commandId: reducerId, now: Date.now(),
857
+ intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
858
+ artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
859
+ } : undefined;
860
+ let preparedResult;
861
+ let preparedFailure;
862
+ const prepared = this.preparationLane.then(async () => {
863
+ if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
864
+ return;
865
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
866
+ return;
867
+ const base = this.replica.executionVersion();
868
+ const pending = this.replica.pendingReducerPatches();
869
+ try {
870
+ const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
871
+ if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
872
+ return;
873
+ preparedResult = { transaction, base, pending: JSON.stringify(pending) };
874
+ }
875
+ catch (error) {
876
+ preparedFailure = error; /* Recheck coverage under coordinated admission. */
877
+ }
878
+ });
879
+ this.preparationLane = prepared.catch(() => undefined);
880
+ this.waitingLocalEdits += 1;
881
+ let entered = false;
882
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
883
+ entered = true;
884
+ this.waitingLocalEdits -= 1;
885
+ await prepared;
377
886
  if (admittedGeneration !== this.outboxScopeGeneration)
378
887
  throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
379
888
  if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
@@ -385,15 +894,51 @@ export class GonvexClient {
385
894
  if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
386
895
  throw new Error("Offline editing window expired. Reconnect before making more changes.");
387
896
  const scope = this.outboxScope;
388
- const reducerId = randomID();
389
- const execution = {
390
- scope: this.replicaScope, commandId: reducerId, now: Date.now(),
391
- intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join(""),
392
- artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
393
- };
897
+ if (!execution || execution.scope !== this.replicaScope)
898
+ throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
899
+ const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
900
+ const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
901
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
902
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
903
+ // A fully resident prediction reads the already published atomic replica.
904
+ // Unrelated snapshots may still be persisting; waiting for those here
905
+ // makes a small edit inherit their disk latency. The shared intent lock
906
+ // and journal still order every edit, and known peer changes must be
907
+ // reconciled before a resident prediction can be reused.
908
+ const reuseResident = preparedResult
909
+ && preparedResult.base === this.replica.executionVersion()
910
+ && preparedResult.pending === JSON.stringify(sharedPatches)
911
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
912
+ && !sharedEntries.some(entry => entry.state === "committed");
913
+ const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
914
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
915
+ && !sharedEntries.some(entry => entry.state === "committed");
916
+ if (!reuseResident && !uncoveredRead)
917
+ await this.replica.synchronizeStorage();
918
+ if (sharedEntries)
919
+ this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
920
+ const executionBaseVersion = this.replica.executionVersion();
394
921
  let transaction;
395
922
  try {
396
- transaction = await this.localExecutor.execute(ref.path, args, this.localSnapshot(true), execution);
923
+ if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
924
+ transaction = preparedResult.transaction;
925
+ }
926
+ else if (this.localBinding.mode === 'portable') {
927
+ // A commit may invalidate preparation while the intent lock is being
928
+ // acquired. Read the newly published atomic replica again before
929
+ // joining disk work that arrived after synchronizeStorage completed.
930
+ try {
931
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
932
+ }
933
+ catch (error) {
934
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
935
+ throw error;
936
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
937
+ }
938
+ }
939
+ else {
940
+ transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
941
+ }
397
942
  }
398
943
  catch (error) {
399
944
  if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
@@ -404,6 +949,10 @@ export class GonvexClient {
404
949
  const patches = transaction?.patches ?? [];
405
950
  this.directOutboxReducerIds.add(reducerId);
406
951
  try {
952
+ // A visible prediction must already have a durable intent. Otherwise a
953
+ // reload immediately after paint can lose an offline edit. Local
954
+ // execution still avoids a server round trip; disk admission is bounded
955
+ // to this intent, rather than persisting the whole replica.
407
956
  const entry = await this.reducerOutbox.enqueue({
408
957
  scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
409
958
  // All local intents form one causal chain, including reads of rows
@@ -423,13 +972,37 @@ export class GonvexClient {
423
972
  }
424
973
  return transaction ? transaction.result : { status: "queued", reducerId };
425
974
  }
975
+ catch (error) {
976
+ this.optimisticReducerIds.delete(reducerId);
977
+ this.replica.rejectCommand(reducerId);
978
+ throw error;
979
+ }
426
980
  finally {
427
981
  this.directOutboxReducerIds.delete(reducerId);
428
- this.scheduleLocalReplay();
982
+ // This transaction already executed against all earlier predictions.
983
+ // Appending it does not invalidate those predictions. Rebase only if
984
+ // the authoritative execution snapshot changed during execution.
985
+ // An uncovered read has no new data to replay against yet. Its
986
+ // hydration subscription schedules replay when coverage arrives;
987
+ // immediately retrying here only locks edits behind the same disk work.
988
+ if (this.replica.executionVersion() !== executionBaseVersion)
989
+ this.scheduleLocalReplay();
429
990
  void this.drainOutbox();
430
991
  }
992
+ })).catch(error => {
993
+ if (!entered)
994
+ this.waitingLocalEdits -= 1;
995
+ this.optimisticReducerIds.delete(reducerId);
996
+ this.replica.rejectCommand(reducerId);
997
+ throw error;
431
998
  });
432
999
  }
1000
+ /** Preload code for a mounted control without reading data or executing an intent. */
1001
+ prepareReducer(ref) {
1002
+ if (ref.localExecution !== 1 || this.manuallyClosed)
1003
+ return Promise.resolve();
1004
+ return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
1005
+ }
433
1006
  /** The single normalized authoritative + optimistic application data store. */
434
1007
  get localReplica() {
435
1008
  return this.replicaView;
@@ -445,19 +1018,33 @@ export class GonvexClient {
445
1018
  replicaEntities(entity, ids) {
446
1019
  return this.replica.entityBatch(entity, ids);
447
1020
  }
1021
+ /** Retain only the data an active view observes; cold rows remain on disk. */
1022
+ retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
1023
+ retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
448
1024
  /** Read rows and server-owned completeness for a persisted Replica Collection. */
449
1025
  replicaCollectionState(ref, args = {}) {
450
1026
  return this.replica.collectionState(this.replicaSignature(ref, args));
451
1027
  }
452
1028
  /** Run the generated Live Query plan over the bounded normalized cache. */
453
- offlineLiveQuery(ref, args = {}) {
1029
+ offlineLiveQuery(ref, args = {}, options = {}) {
454
1030
  if (!ref.live?.plan) {
455
1031
  return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
456
1032
  }
457
1033
  const queryArgs = isJsonRecord(args) ? args : {};
458
- return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity));
1034
+ 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), {
1035
+ context: {
1036
+ ...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
1037
+ ...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
1038
+ ...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
1039
+ },
1040
+ relationRows: (table, where) => {
1041
+ const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
1042
+ return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
1043
+ && readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
1044
+ },
1045
+ });
459
1046
  }
460
- /** Number of reducers waiting for a definitive server result. */
1047
+ /** Number of reducers still queued for delivery (excludes failed and rejected intents). */
461
1048
  async outboxCount() {
462
1049
  await this.outboxReady;
463
1050
  return this.reducerOutbox.count(this.outboxScope);
@@ -803,8 +1390,10 @@ export class GonvexClient {
803
1390
  this.authRetriedAfterError = false;
804
1391
  const directive = replicaDirectiveFromAuthResult(message.result);
805
1392
  const localIdentity = localIdentityFromAuthResult(message.result);
806
- if (localIdentity)
1393
+ if (localIdentity) {
807
1394
  this.localIdentity = localIdentity;
1395
+ void this.localExecutor?.ready.catch(() => undefined);
1396
+ }
808
1397
  if (!directive) {
809
1398
  this.authInFlight = false;
810
1399
  if (!this.auth.tenant) {
@@ -946,6 +1535,7 @@ export class GonvexClient {
946
1535
  this.unsubscribeLocal?.();
947
1536
  for (const close of this.localCollectionClosers.splice(0))
948
1537
  close();
1538
+ this.localCollectionKeys.clear();
949
1539
  this.localExecutor?.close();
950
1540
  this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
951
1541
  if (isEphemeralOutboxScope(this.outboxScope)) {
@@ -982,6 +1572,8 @@ export class GonvexClient {
982
1572
  this.outboxDrainTimer = undefined;
983
1573
  }
984
1574
  this.unsubscribeOutbox();
1575
+ this.unsubscribePeerOutbox?.();
1576
+ this.unsubscribePeerReplica?.();
985
1577
  this.unsubscribeBrowserOnline?.();
986
1578
  this.handlers.clear();
987
1579
  this.querySubscriptions.clear();
@@ -999,6 +1591,7 @@ export class GonvexClient {
999
1591
  this.notifyConnectionState();
1000
1592
  this.connectionStateHandlers.clear();
1001
1593
  this.supportCommandHandlers.clear();
1594
+ this.replica.dispose();
1002
1595
  const closedError = new Error("Gonvex client is closed");
1003
1596
  for (const pending of this.pendingNativeErrors.splice(0))
1004
1597
  pending.reject(closedError);
@@ -1335,7 +1928,9 @@ export class GonvexClient {
1335
1928
  // could overwrite a result already accepted on the same connection.
1336
1929
  return subscription.revisionSocketGeneration !== this.socketGeneration;
1337
1930
  }
1338
- subscribeReplicaTransport(ref, args = {}, onMessage) {
1931
+ subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
1932
+ if (metadataOnly)
1933
+ this.replicaMetadataListeners.add(onMessage);
1339
1934
  this.connect();
1340
1935
  const key = querySubscriptionKey(ref, args);
1341
1936
  const existing = this.replicaSubscriptions.get(key);
@@ -1345,10 +1940,14 @@ export class GonvexClient {
1345
1940
  existing.unsubscribeTimer = undefined;
1346
1941
  }
1347
1942
  existing.listeners.add(onMessage);
1943
+ // A reset may arrive during the retention grace period with no listeners.
1944
+ // Its reopen was correctly deferred; the next subscriber must resume it.
1945
+ if (!existing.isUpToDate && !existing.opening)
1946
+ this.sendReplicaOpen(existing);
1348
1947
  if (existing.lastMessage) {
1349
1948
  queueMicrotask(() => {
1350
1949
  if (existing.listeners.has(onMessage) && existing.lastMessage) {
1351
- onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
1950
+ onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
1352
1951
  }
1353
1952
  });
1354
1953
  }
@@ -1386,12 +1985,16 @@ export class GonvexClient {
1386
1985
  return this.subscribeReplicaTransport(ref, args, onMessage);
1387
1986
  }
1388
1987
  /** Watch a bounded Replica Collection through the normalized Local Replica. */
1389
- watchReplica(ref, args = {}) {
1988
+ watchReplica(ref, args = {}, options = {}) {
1390
1989
  const key = querySubscriptionKey(ref, args);
1391
1990
  const updateHandlers = new Set();
1392
1991
  let latestError;
1393
1992
  let snapshotVersion = -1;
1394
1993
  let snapshotRows;
1994
+ const readRows = () => {
1995
+ const next = this.replica.liveQuerySnapshot(key).rows;
1996
+ return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
1997
+ };
1395
1998
  let stateVersion = -1;
1396
1999
  let stateFreshness;
1397
2000
  let stateIsUpToDate;
@@ -1403,45 +2006,57 @@ export class GonvexClient {
1403
2006
  };
1404
2007
  // Keep the ReplicaSubscription as transport/reconciliation state only. The
1405
2008
  // value returned by this watch always comes from normalized LocalReplica.
1406
- const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
1407
- if (message.type === "replica.error") {
1408
- latestError = new Error(message.error);
1409
- notify();
1410
- }
1411
- else if (message.type === "replica.syncing" || message.type === "replica.reset") {
1412
- latestError = undefined;
1413
- notify();
1414
- }
1415
- else if (message.type === "replica.ready") {
1416
- latestError = undefined;
1417
- notify();
1418
- }
1419
- else if (message.type === "replica.snapshot") {
1420
- latestError = undefined;
1421
- }
1422
- });
1423
- const unsubscribeReplica = this.replica.subscribe(notify);
1424
- const unsubscribeScope = this.onSessionScopeChange(() => {
1425
- latestError = undefined;
2009
+ let stop;
2010
+ const start = () => {
2011
+ if (stop)
2012
+ return;
1426
2013
  snapshotVersion = -1;
1427
- snapshotRows = undefined;
1428
2014
  stateVersion = -1;
1429
- stateFreshness = undefined;
1430
- stateIsUpToDate = undefined;
1431
- snapshotState = undefined;
1432
- notify();
1433
- });
2015
+ const releaseRows = this.replica.retainWindow(key);
2016
+ const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
2017
+ if (message.type === "replica.error") {
2018
+ latestError = new Error(message.error);
2019
+ notify();
2020
+ }
2021
+ else if (message.type === "replica.syncing" || message.type === "replica.reset") {
2022
+ latestError = undefined;
2023
+ notify();
2024
+ }
2025
+ else if (message.type === "replica.ready") {
2026
+ latestError = undefined;
2027
+ notify();
2028
+ }
2029
+ else if (message.type === "replica.snapshot") {
2030
+ latestError = undefined;
2031
+ }
2032
+ }, true);
2033
+ const unsubscribeReplica = this.replica.subscribe(notify);
2034
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2035
+ latestError = undefined;
2036
+ snapshotVersion = -1;
2037
+ snapshotRows = undefined;
2038
+ stateVersion = -1;
2039
+ stateFreshness = undefined;
2040
+ stateIsUpToDate = undefined;
2041
+ snapshotState = undefined;
2042
+ notify();
2043
+ });
2044
+ stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2045
+ };
2046
+ // React can discard a render before subscribing. Such watches must own no resources.
2047
+ if (!options.deferStart)
2048
+ start();
1434
2049
  return {
1435
2050
  localReplicaResult: () => {
1436
2051
  if (latestError)
1437
2052
  throw latestError;
1438
2053
  if (!this.replica.hasLiveQuery(key))
1439
2054
  return undefined;
1440
- const version = this.replica.windowVersion(key);
2055
+ const version = this.replica.windowRowsVersion(key);
1441
2056
  if (snapshotVersion === version)
1442
2057
  return snapshotRows;
1443
2058
  snapshotVersion = version;
1444
- snapshotRows = this.replica.liveQuery(key).rows;
2059
+ snapshotRows = readRows();
1445
2060
  return snapshotRows;
1446
2061
  },
1447
2062
  localReplicaState: () => {
@@ -1459,7 +2074,12 @@ export class GonvexClient {
1459
2074
  stateVersion = version;
1460
2075
  stateFreshness = freshness;
1461
2076
  stateIsUpToDate = isUpToDate;
1462
- const state = this.replica.collectionState(key);
2077
+ const rowsVersion = this.replica.windowRowsVersion(key);
2078
+ if (snapshotVersion !== rowsVersion) {
2079
+ snapshotVersion = rowsVersion;
2080
+ snapshotRows = readRows();
2081
+ }
2082
+ const state = this.replica.collectionState(key, snapshotRows);
1463
2083
  snapshotState = {
1464
2084
  ...state,
1465
2085
  isUpToDate,
@@ -1475,6 +2095,7 @@ export class GonvexClient {
1475
2095
  isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
1476
2096
  }),
1477
2097
  onUpdate(handler) {
2098
+ start();
1478
2099
  if (releaseTimer) {
1479
2100
  clearTimeout(releaseTimer);
1480
2101
  releaseTimer = undefined;
@@ -1492,9 +2113,7 @@ export class GonvexClient {
1492
2113
  releaseTimer = undefined;
1493
2114
  if (updateHandlers.size > 0)
1494
2115
  return;
1495
- unsubscribeTransport();
1496
- unsubscribeReplica();
1497
- unsubscribeScope();
2116
+ stop?.();
1498
2117
  }, 0);
1499
2118
  };
1500
2119
  },
@@ -1505,7 +2124,7 @@ export class GonvexClient {
1505
2124
  * latest query result is retained only as the transport-shaped skeleton;
1506
2125
  * its row window is always rebuilt from LocalReplica membership/entities.
1507
2126
  */
1508
- watchLiveQuery(ref, args = {}) {
2127
+ watchLiveQuery(ref, args = {}, options = {}) {
1509
2128
  const key = querySubscriptionKey(ref, args);
1510
2129
  const updateHandlers = new Set();
1511
2130
  let transportResult;
@@ -1525,39 +2144,51 @@ export class GonvexClient {
1525
2144
  handler();
1526
2145
  });
1527
2146
  };
1528
- const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
1529
- if (message.type === "query.result") {
1530
- transportResult = message.result;
2147
+ let stop;
2148
+ const start = () => {
2149
+ if (stop)
2150
+ return;
2151
+ transportResult = undefined;
2152
+ snapshotToken = "";
2153
+ const releaseRows = this.replica.retainWindow(key);
2154
+ const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
2155
+ if (message.type === "query.result") {
2156
+ transportResult = message.result;
2157
+ transportGeneration += 1;
2158
+ latestError = undefined;
2159
+ // LocalReplica has already published its atomic window swap before
2160
+ // this callback is emitted, so this is the single initial UI wake-up.
2161
+ notify();
2162
+ }
2163
+ else if (message.type === "query.error") {
2164
+ latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
2165
+ code: "server", path: ref.path, operation: "query",
2166
+ });
2167
+ notify();
2168
+ }
2169
+ });
2170
+ // During the initial query result, LocalReplica notifies before the
2171
+ // transport-shaped skeleton is installed above. Suppress that empty
2172
+ // intermediate wake-up; later transactions notify directly from the
2173
+ // normalized store.
2174
+ const unsubscribeReplica = this.replica.subscribe(() => {
2175
+ if (transportResult !== undefined || this.replica.hasLiveQuery(key))
2176
+ notify();
2177
+ });
2178
+ void this.replicaReady.then(() => notify());
2179
+ const unsubscribeScope = this.onSessionScopeChange(() => {
2180
+ transportResult = undefined;
1531
2181
  transportGeneration += 1;
2182
+ snapshotToken = "";
2183
+ snapshotResult = undefined;
1532
2184
  latestError = undefined;
1533
- // LocalReplica has already published its atomic window swap before
1534
- // this callback is emitted, so this is the single initial UI wake-up.
1535
- notify();
1536
- }
1537
- else if (message.type === "query.error") {
1538
- latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
1539
- code: "server", path: ref.path, operation: "query",
1540
- });
1541
2185
  notify();
1542
- }
1543
- });
1544
- // During the initial query result, LocalReplica notifies before the
1545
- // transport-shaped skeleton is installed above. Suppress that empty
1546
- // intermediate wake-up; later transactions notify directly from the
1547
- // normalized store.
1548
- const unsubscribeReplica = this.replica.subscribe(() => {
1549
- if (transportResult !== undefined || this.replica.hasLiveQuery(key))
1550
- notify();
1551
- });
1552
- void this.replicaReady.then(() => notify());
1553
- const unsubscribeScope = this.onSessionScopeChange(() => {
1554
- transportResult = undefined;
1555
- transportGeneration += 1;
1556
- snapshotToken = "";
1557
- snapshotResult = undefined;
1558
- latestError = undefined;
1559
- notify();
1560
- });
2186
+ });
2187
+ stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
2188
+ };
2189
+ // React can discard a render before subscribing. Such watches must own no resources.
2190
+ if (!options.deferStart)
2191
+ start();
1561
2192
  return {
1562
2193
  localLiveQueryResult: () => {
1563
2194
  if (latestError)
@@ -1592,6 +2223,7 @@ export class GonvexClient {
1592
2223
  return snapshotResult;
1593
2224
  },
1594
2225
  onUpdate(handler) {
2226
+ start();
1595
2227
  if (releaseTimer) {
1596
2228
  clearTimeout(releaseTimer);
1597
2229
  releaseTimer = undefined;
@@ -1609,9 +2241,7 @@ export class GonvexClient {
1609
2241
  releaseTimer = undefined;
1610
2242
  if (updateHandlers.size > 0)
1611
2243
  return;
1612
- unsubscribeQuery();
1613
- unsubscribeReplica();
1614
- unsubscribeScope();
2244
+ stop?.();
1615
2245
  }, 0);
1616
2246
  };
1617
2247
  },
@@ -1656,7 +2286,7 @@ export class GonvexClient {
1656
2286
  scope,
1657
2287
  };
1658
2288
  await this.replica.replaceWindow(window);
1659
- const snapshot = { ...message, result: this.replica.windowRows(subscription.key) };
2289
+ const snapshot = { ...message, result: [] };
1660
2290
  subscription.lastMessage = snapshot;
1661
2291
  this.emitReplicaMessage(subscription, snapshot, scope);
1662
2292
  return;
@@ -1667,12 +2297,20 @@ export class GonvexClient {
1667
2297
  return;
1668
2298
  this.clearReplicaRetry(subscription, true);
1669
2299
  raiseReplicaCursorFloor(subscription, message.cursor);
2300
+ const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
2301
+ // Incremental integrity is safe only for full projected row images. Older
2302
+ // partial-row protocols keep the full normalized rehash on replica.ready.
2303
+ const canCarryHashes = prior?.hashes && subscription.columns?.length
2304
+ && upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
2305
+ const hashes = message.hashes ?? (canCarryHashes
2306
+ ? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
2307
+ : undefined);
1670
2308
  await this.replica.applyWindowDelta({
1671
2309
  signature: subscription.key,
1672
2310
  kind: "replica",
1673
2311
  entity: subscription.entity,
1674
2312
  key: prior?.key ?? "id",
1675
- upserts: (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
2313
+ upserts,
1676
2314
  deleted: message.deleted ?? [],
1677
2315
  completeness: prior?.completeness ?? "partial",
1678
2316
  source: "server",
@@ -1683,13 +2321,11 @@ export class GonvexClient {
1683
2321
  orderDirection: prior?.orderDirection,
1684
2322
  maxRows: prior?.maxRows,
1685
2323
  maxBytes: prior?.maxBytes,
1686
- // A delta invalidates the prior full integrity map unless the server
1687
- // supplied a complete replacement map with this frame.
1688
- hashes: message.hashes,
2324
+ hashes,
1689
2325
  });
1690
2326
  const snapshot = {
1691
2327
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1692
- result: this.replica.windowRows(subscription.key), cursor: message.cursor,
2328
+ result: [], cursor: message.cursor,
1693
2329
  key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
1694
2330
  orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
1695
2331
  };
@@ -1839,9 +2475,15 @@ export class GonvexClient {
1839
2475
  emitReplicaMessage(subscription, message, scope = this.replicaScope) {
1840
2476
  if (scope !== this.replicaScope || subscription.scope !== scope)
1841
2477
  return;
1842
- const outgoing = this.materializeReplicaMessage(subscription, message);
1843
- for (const listener of Array.from(subscription.listeners))
1844
- listener(outgoing);
2478
+ // Hook observers consume metadata only; their rows come from Local Replica.
2479
+ // Materialize a payload only when a public transport consumer needs it.
2480
+ let outgoing;
2481
+ for (const listener of Array.from(subscription.listeners)) {
2482
+ if (this.replicaMetadataListeners.has(listener))
2483
+ listener(message);
2484
+ else
2485
+ listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
2486
+ }
1845
2487
  }
1846
2488
  materializeReplicaMessage(subscription, message) {
1847
2489
  if (message.type !== "replica.snapshot")
@@ -1887,7 +2529,7 @@ export class GonvexClient {
1887
2529
  subscription.isUpToDate = false;
1888
2530
  const message = {
1889
2531
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1890
- result: this.replica.windowRows(subscription.key), cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
2532
+ result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
1891
2533
  key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
1892
2534
  orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
1893
2535
  };
@@ -1998,6 +2640,9 @@ export class GonvexClient {
1998
2640
  ? this.restoreOutbox(scope, generation)
1999
2641
  : this.restoreLocalSession(scope, generation);
2000
2642
  this.outboxReady = ready;
2643
+ // Never show the previous identity's intents under the new one.
2644
+ this.publishIntents([]);
2645
+ this.refreshIntents();
2001
2646
  return ready;
2002
2647
  }
2003
2648
  async activateReplicaDirective(directive) {
@@ -2031,7 +2676,6 @@ export class GonvexClient {
2031
2676
  // and sends a second time with the same command ID.
2032
2677
  this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
2033
2678
  await this.outboxReady;
2034
- this.ensureLocalCollections();
2035
2679
  }
2036
2680
  quarantineReplicaScope() {
2037
2681
  this.localIdentity = undefined;
@@ -2059,6 +2703,7 @@ export class GonvexClient {
2059
2703
  }
2060
2704
  async restoreOutbox(scope, generation) {
2061
2705
  await this.replicaReady;
2706
+ await this.replica.synchronizeStorage();
2062
2707
  const entries = await this.reducerOutbox.loadAll(scope);
2063
2708
  if (this.manuallyClosed
2064
2709
  || generation !== this.outboxScopeGeneration
@@ -2076,6 +2721,8 @@ export class GonvexClient {
2076
2721
  for (const entry of entries) {
2077
2722
  if (entry.localExecution && this.localExecutor)
2078
2723
  continue;
2724
+ if (!outboxEntryIsLive(entry))
2725
+ continue;
2079
2726
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
2080
2727
  await this.reducerOutbox.ack(entry.id);
2081
2728
  continue;
@@ -2092,11 +2739,15 @@ export class GonvexClient {
2092
2739
  // If this scope was installed after the socket authenticated, no reconnect
2093
2740
  // or new enqueue may occur to wake the queue. The await inside drainOutbox
2094
2741
  // yields until this restore promise resolves, then safely resumes it.
2742
+ this.refreshIntents();
2095
2743
  void this.drainOutbox();
2096
2744
  }
2097
2745
  addOptimisticReducer(reducerId, patches, accepted = false) {
2098
- if (patches.length === 0 || this.optimisticReducerIds.has(reducerId))
2746
+ if (patches.length === 0) {
2747
+ this.optimisticReducerIds.delete(reducerId);
2748
+ this.replica.rejectCommand(reducerId);
2099
2749
  return;
2750
+ }
2100
2751
  this.optimisticReducerIds.add(reducerId);
2101
2752
  this.replica.applyOptimistic(reducerId, patches);
2102
2753
  }
@@ -2117,95 +2768,185 @@ export class GonvexClient {
2117
2768
  }
2118
2769
  async drainOutbox() {
2119
2770
  await this.outboxReady;
2120
- if (this.drainingOutbox
2121
- || this.manuallyClosed
2122
- || !this.canSendReducerNow())
2771
+ if (this.drainingOutbox) {
2772
+ // The running drain may already be past the entry this wake-up is for
2773
+ // (for example a short backoff timer firing before the drain returned).
2774
+ this.outboxDrainRequested = true;
2775
+ return;
2776
+ }
2777
+ if (this.manuallyClosed || !this.canSendReducerNow())
2123
2778
  return;
2124
2779
  const drainScope = this.outboxScope;
2125
2780
  this.drainingOutbox = true;
2781
+ this.outboxDrainRequested = false;
2126
2782
  try {
2127
- while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
2128
- const scope = this.outboxScope;
2129
- const entry = await this.inLocalLane(async () => {
2130
- const next = await this.reducerOutbox.nextReady(scope, Date.now());
2131
- if (next)
2132
- await this.reducerOutbox.markInflight(next.id);
2133
- return next;
2134
- });
2135
- if (!entry)
2136
- return;
2137
- if (scope !== this.outboxScope)
2138
- return;
2139
- if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
2140
- // Scope recovery may observe an inflight row created by this live
2141
- // process and reset it to pending under the assumption that a prior
2142
- // process crashed. The foreground call still owns that command ID.
2143
- // Restore the durable marker and wait for its real result instead of
2144
- // registering a second response handler for the same command.
2145
- await this.reducerOutbox.markInflight(entry.id);
2146
- return;
2147
- }
2148
- if (!this.canSendReducerNow()) {
2149
- await this.reducerOutbox.markPending(entry.id);
2783
+ await this.coordinateOutbox("delivery", async () => {
2784
+ if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
2150
2785
  return;
2151
- }
2152
- await this.reducerOutbox.markInflight(entry.id);
2153
- if (scope !== this.outboxScope)
2154
- return;
2155
- if (!this.canSendReducerNow()) {
2156
- await this.reducerOutbox.markPending(entry.id);
2157
- return;
2158
- }
2159
- try {
2160
- 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);
2161
- await this.reducerOutbox.markCommitted(entry.id);
2162
- if (entry.localExecution && this.localExecutor) {
2163
- await this.inLocalLane(async () => {
2164
- await this.reducerOutbox.ack(entry.id);
2165
- if (scope === this.outboxScope)
2166
- await this.rebaseLocalEntries();
2167
- });
2786
+ if (this.sharedOutboxStore)
2787
+ await this.reducerOutbox.recoverInflight?.(drainScope);
2788
+ while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
2789
+ const scope = this.outboxScope;
2790
+ const entry = await this.inLocalLane(async () => {
2791
+ const next = await this.reducerOutbox.nextReady(scope, Date.now());
2792
+ // A concurrent discard (this or another tab) fences the entry; a
2793
+ // refused transition means it must not be sent.
2794
+ if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
2795
+ return null;
2796
+ return next;
2797
+ });
2798
+ if (entry === null)
2799
+ continue;
2800
+ if (!entry) {
2801
+ await this.scheduleNextOutboxAttempt(scope);
2802
+ return;
2168
2803
  }
2169
- else if ((entry.patches?.length ?? 0) > 0) {
2170
- await this.settleOptimisticReducer(entry.idempotencyKey);
2804
+ if (scope !== this.outboxScope)
2805
+ return;
2806
+ if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
2807
+ // Scope recovery may observe an inflight row created by this live
2808
+ // process and reset it to pending under the assumption that a prior
2809
+ // process crashed. The foreground call still owns that command ID.
2810
+ // Restore the durable marker and wait for its real result instead of
2811
+ // registering a second response handler for the same command.
2812
+ await this.reducerOutbox.markInflight(entry.id);
2813
+ return;
2171
2814
  }
2172
- else {
2173
- await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2815
+ if (!this.canSendReducerNow()) {
2816
+ await this.reducerOutbox.markPending(entry.id);
2817
+ return;
2174
2818
  }
2175
- }
2176
- catch (error) {
2177
- if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
2819
+ if ((await this.reducerOutbox.markInflight(entry.id)) === false)
2820
+ continue;
2821
+ if (scope !== this.outboxScope)
2822
+ return;
2823
+ if (!this.canSendReducerNow()) {
2178
2824
  await this.reducerOutbox.markPending(entry.id);
2179
- this.requireClientUpdate(error.message);
2180
2825
  return;
2181
2826
  }
2182
- if (error instanceof GonvexClientError && error.code === "server") {
2827
+ try {
2828
+ 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);
2829
+ this.unauthenticatedRetries = 0;
2830
+ await this.reducerOutbox.markCommitted(entry.id);
2183
2831
  if (entry.localExecution && this.localExecutor) {
2184
2832
  await this.inLocalLane(async () => {
2185
2833
  await this.reducerOutbox.ack(entry.id);
2186
- if (scope === this.outboxScope) {
2834
+ if (scope === this.outboxScope)
2187
2835
  await this.rebaseLocalEntries();
2188
- this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2189
- }
2190
2836
  });
2191
2837
  }
2192
- else
2193
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2194
- continue;
2838
+ else if ((entry.patches?.length ?? 0) > 0) {
2839
+ await this.settleOptimisticReducer(entry.idempotencyKey);
2840
+ }
2841
+ else {
2842
+ await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2843
+ }
2844
+ }
2845
+ catch (error) {
2846
+ const disposition = deliveryErrorClass(error);
2847
+ if (disposition === "rejected") {
2848
+ // Keep today's rollback and rebase, but record the rejection
2849
+ // durably instead of deleting the intent without a trace.
2850
+ if (entry.localExecution && this.localExecutor) {
2851
+ await this.inLocalLane(async () => {
2852
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2853
+ if (scope === this.outboxScope) {
2854
+ await this.rebaseLocalEntries(true);
2855
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2856
+ }
2857
+ });
2858
+ }
2859
+ else {
2860
+ this.optimisticReducerIds.delete(entry.idempotencyKey);
2861
+ this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
2862
+ this.replica.rejectCommand(entry.idempotencyKey);
2863
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2864
+ if (scope === this.outboxScope)
2865
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2866
+ }
2867
+ continue;
2868
+ }
2869
+ const outcome = await this.recordDeliveryFailure(entry, error, disposition);
2870
+ // A parked intent keeps its prediction but no longer blocks the
2871
+ // chain; later intents proceed and the server validates them.
2872
+ if (outcome === "parked")
2873
+ continue;
2874
+ return;
2195
2875
  }
2196
- await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
2197
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
2198
- return;
2199
2876
  }
2200
- }
2877
+ });
2201
2878
  }
2202
2879
  finally {
2203
2880
  this.drainingOutbox = false;
2204
- if (!this.manuallyClosed && drainScope !== this.outboxScope) {
2881
+ this.scheduleLocalReplay();
2882
+ const requested = this.outboxDrainRequested;
2883
+ this.outboxDrainRequested = false;
2884
+ if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
2205
2885
  void this.drainOutbox();
2206
2886
  }
2207
2887
  }
2208
2888
  }
2889
+ /**
2890
+ * Record a non-rejection delivery failure and arm the next attempt.
2891
+ * - update_required: keep the intent pending and stop for an app update.
2892
+ * - unauthenticated: keep the intent, re-authenticate, retry with backoff
2893
+ * that never spends the retry budget.
2894
+ * - network: connectivity loss; retried on reconnect without spending budget.
2895
+ * - transient (and timeouts): exponential backoff, parked as `failed` once
2896
+ * the retry budget is spent.
2897
+ */
2898
+ async recordDeliveryFailure(entry, error, disposition) {
2899
+ const message = reducerErrorMessage(error);
2900
+ if (disposition === "update_required") {
2901
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
2902
+ this.requireClientUpdate(message);
2903
+ return "update_required";
2904
+ }
2905
+ if (disposition === "unauthenticated") {
2906
+ const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
2907
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
2908
+ this.requestReauthentication();
2909
+ this.scheduleOutboxDrain(delay);
2910
+ return "retrying";
2911
+ }
2912
+ const counted = disposition === "transient";
2913
+ const updated = await this.reducerOutbox.fail(entry.id, message, {
2914
+ errorClass: disposition,
2915
+ countAttempt: counted,
2916
+ maxAttempts: this.outboxRetry.maxAttempts,
2917
+ maxBackoffMs: this.outboxRetry.maxBackoffMs,
2918
+ ...(counted ? {} : { delayMs: 1_000 }),
2919
+ });
2920
+ if (updated && updated.state === "failed")
2921
+ return "parked";
2922
+ const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
2923
+ this.scheduleOutboxDrain(delay);
2924
+ return "retrying";
2925
+ }
2926
+ /**
2927
+ * Arm a timer for the earliest backed-off entry. Timer clocks and
2928
+ * `Date.now()` can disagree by a millisecond, so a wake-up may find its
2929
+ * entry not quite due; without this the queue would wait for an unrelated
2930
+ * event to resume.
2931
+ */
2932
+ async scheduleNextOutboxAttempt(scope) {
2933
+ const now = Date.now();
2934
+ let next = Infinity;
2935
+ for (const entry of await this.reducerOutbox.list(scope)) {
2936
+ if (entry.state === "pending" && entry.nextAttemptAt > now)
2937
+ next = Math.min(next, entry.nextAttemptAt);
2938
+ }
2939
+ if (Number.isFinite(next) && scope === this.outboxScope)
2940
+ this.scheduleOutboxDrain(next - now + 1);
2941
+ }
2942
+ /** Re-send auth on the open socket so a lost tenant session is restored. */
2943
+ requestReauthentication() {
2944
+ if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
2945
+ return;
2946
+ if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
2947
+ return;
2948
+ this.sendAuth(false);
2949
+ }
2209
2950
  scheduleOutboxDrain(delay) {
2210
2951
  if (this.manuallyClosed)
2211
2952
  return;
@@ -2245,7 +2986,6 @@ export class GonvexClient {
2245
2986
  }
2246
2987
  this.directOutboxReducerIds.add(reducerId);
2247
2988
  let entryId;
2248
- let entryAttempts = 0;
2249
2989
  try {
2250
2990
  const scope = this.outboxScope;
2251
2991
  const entry = await this.reducerOutbox.enqueue({
@@ -2258,7 +2998,6 @@ export class GonvexClient {
2258
2998
  state: "inflight",
2259
2999
  });
2260
3000
  entryId = entry.id;
2261
- entryAttempts = entry.attempts;
2262
3001
  if (this.manuallyClosed) {
2263
3002
  await this.reducerOutbox.ack(entry.id);
2264
3003
  throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
@@ -2286,14 +3025,13 @@ export class GonvexClient {
2286
3025
  return result;
2287
3026
  }
2288
3027
  catch (error) {
2289
- if (isQueueableReducerError(error) && options.offline === "queue") {
3028
+ const disposition = deliveryErrorClass(error);
3029
+ if (disposition !== "rejected" && options.offline === "queue") {
2290
3030
  const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
2291
3031
  if (queuedEntryId !== undefined) {
2292
- await this.reducerOutbox.fail(queuedEntryId, reducerErrorMessage(error));
2293
- // `fail` deliberately records backoff, but it does not own the
2294
- // client's timer. The foreground queueable path must schedule the
2295
- // next deterministic drain just like the background drain path.
2296
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
3032
+ // Records backoff (or parks the entry) and arms the client's timer,
3033
+ // exactly like the background drain path.
3034
+ await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
2297
3035
  }
2298
3036
  return { status: "queued", reducerId };
2299
3037
  }
@@ -2514,8 +3252,11 @@ export class GonvexClient {
2514
3252
  return;
2515
3253
  }
2516
3254
  if (kind === "reducer" && message.type === "reducer.result") {
2517
- this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2518
3255
  const complete = () => {
3256
+ // A collection frame can advance the shared cursor before another
3257
+ // collection's rows and membership have arrived. Retire the local
3258
+ // prediction only after the commit's durable watermark barrier.
3259
+ this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2519
3260
  settle();
2520
3261
  this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
2521
3262
  resolve(message.result);
@@ -2539,7 +3280,11 @@ export class GonvexClient {
2539
3280
  if (kind === "reducer" && message.type === "reducer.error") {
2540
3281
  settle();
2541
3282
  this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
2542
- reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
3283
+ const errorClass = reducerErrorClassFromMessage(message);
3284
+ reject(new GonvexClientError(message.error, {
3285
+ code: "server", path: ref.path, operation: kind,
3286
+ ...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
3287
+ }));
2543
3288
  }
2544
3289
  if (kind === "action" && message.type === "action.result") {
2545
3290
  const complete = () => {
@@ -3160,15 +3905,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
3160
3905
  const current = result[head];
3161
3906
  return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
3162
3907
  }
3908
+ // Generated references and their plans are immutable module metadata. Retain
3909
+ // one canonical contract per reference, not a fresh sorted serialization for
3910
+ // every React watch/read. Weak keys do not retain replaced modules or callers.
3911
+ const subscriptionContracts = new WeakMap();
3163
3912
  function querySubscriptionKey(ref, args) {
3164
- const contract = {
3165
- scope: ref.scope ?? "tenant",
3166
- delivery: ref.delivery ?? "oneShot",
3167
- live: ref.live
3168
- ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
3169
- : null,
3170
- };
3171
- return `${ref.path}\u0000${stableStringify(args)}\u0000${stableStringify(contract)}`;
3913
+ let cached = subscriptionContracts.get(ref);
3914
+ if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
3915
+ const contract = {
3916
+ scope: ref.scope ?? "tenant",
3917
+ delivery: ref.delivery ?? "oneShot",
3918
+ live: ref.live
3919
+ ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
3920
+ : null,
3921
+ };
3922
+ cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
3923
+ subscriptionContracts.set(ref, cached);
3924
+ }
3925
+ const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
3926
+ const previous = cached.keys.get(argumentsKey);
3927
+ if (previous !== undefined)
3928
+ return previous;
3929
+ const key = `${argumentsKey}\u0000${cached.value}`;
3930
+ // Search text and pagination may produce unbounded argument combinations.
3931
+ if (cached.keys.size >= 16)
3932
+ cached.keys.delete(cached.keys.keys().next().value);
3933
+ cached.keys.set(argumentsKey, key);
3934
+ return key;
3172
3935
  }
3173
3936
  function countPendingCalls(calls, kind) {
3174
3937
  let count = 0;
@@ -3178,9 +3941,96 @@ function countPendingCalls(calls, kind) {
3178
3941
  }
3179
3942
  return count;
3180
3943
  }
3181
- function isQueueableReducerError(error) {
3182
- return error instanceof GonvexClientError
3183
- && (error.code === "disconnected" || error.code === "timeout");
3944
+ /**
3945
+ * How the outbox treats one failed delivery. Runtimes before
3946
+ * 0.5.2-staging.15 send no class: their server errors stay rejections, as
3947
+ * before, except the two cases the legacy runtime could only express in prose.
3948
+ */
3949
+ function deliveryErrorClass(error) {
3950
+ if (!(error instanceof GonvexClientError))
3951
+ return "transient";
3952
+ switch (error.code) {
3953
+ case "server": return error.errorClass ?? "rejected";
3954
+ case "timeout": return "transient";
3955
+ default: return "network";
3956
+ }
3957
+ }
3958
+ const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
3959
+ function reducerErrorClassFromMessage(message) {
3960
+ if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
3961
+ return message.class;
3962
+ }
3963
+ // Legacy runtimes: the only structured signals were these literal messages.
3964
+ if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
3965
+ return "update_required";
3966
+ if (message.error === legacyUnauthenticatedReducerError)
3967
+ return "unauthenticated";
3968
+ return undefined;
3969
+ }
3970
+ function positiveOr(value, fallback) {
3971
+ return typeof value === "number" && value > 0 ? value : fallback;
3972
+ }
3973
+ function summarizeArgs(args) {
3974
+ let text;
3975
+ try {
3976
+ text = JSON.stringify(args) ?? "";
3977
+ }
3978
+ catch {
3979
+ text = String(args);
3980
+ }
3981
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
3982
+ }
3983
+ function outboxIntentFromEntry(entry) {
3984
+ const entities = new Map();
3985
+ for (const patch of entry.patches ?? []) {
3986
+ const entity = patch.entity ?? patch.collection;
3987
+ if (!entity)
3988
+ continue;
3989
+ entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
3990
+ }
3991
+ return {
3992
+ id: entry.idempotencyKey,
3993
+ entryId: entry.id,
3994
+ reducer: entry.path,
3995
+ state: entry.state,
3996
+ attempts: entry.attempts,
3997
+ ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
3998
+ ...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
3999
+ createdAt: entry.createdAt,
4000
+ nextAttemptAt: entry.nextAttemptAt,
4001
+ ...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
4002
+ args: entry.args,
4003
+ argsSummary: summarizeArgs(entry.args),
4004
+ entities: [...entities.values()],
4005
+ };
4006
+ }
4007
+ function sameIntents(left, right) {
4008
+ if (left.length !== right.length)
4009
+ return false;
4010
+ for (let index = 0; index < left.length; index += 1) {
4011
+ const a = left[index], b = right[index];
4012
+ if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
4013
+ || a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
4014
+ || a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
4015
+ || a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
4016
+ return false;
4017
+ }
4018
+ return true;
4019
+ }
4020
+ /** Row status from a list of intents: failed > rejected > syncing. */
4021
+ export function entityStatusFromIntents(intents, entity, id) {
4022
+ let status;
4023
+ for (const intent of intents) {
4024
+ if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
4025
+ continue;
4026
+ if (intent.state === "failed")
4027
+ return "failed";
4028
+ if (intent.state === "rejected")
4029
+ status = "rejected";
4030
+ else if (intent.state !== "committed" && status === undefined)
4031
+ status = "syncing";
4032
+ }
4033
+ return status;
3184
4034
  }
3185
4035
  function reducerErrorMessage(error) {
3186
4036
  return error instanceof Error ? error.message : String(error);
@@ -3202,6 +4052,9 @@ function stableStringify(value) {
3202
4052
  .join(",")}}`;
3203
4053
  }
3204
4054
  function utf8KeyCompare(left, right) {
4055
+ // ASCII keys have identical UTF-8 and JavaScript lexical ordering.
4056
+ if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
4057
+ return left < right ? -1 : left > right ? 1 : 0;
3205
4058
  const leftBytes = new TextEncoder().encode(left);
3206
4059
  const rightBytes = new TextEncoder().encode(right);
3207
4060
  const length = Math.min(leftBytes.length, rightBytes.length);
@@ -3222,9 +4075,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
3222
4075
  const key = replicaRowKeyValue(row, keyField);
3223
4076
  if (!key || seen.has(key))
3224
4077
  continue;
3225
- const size = replicaJSONSize(row);
3226
4078
  if (maxRows && kept.length >= maxRows)
3227
4079
  break;
4080
+ const size = maxBytes ? replicaJSONSize(row) : 0;
3228
4081
  if (maxBytes && bytes + size > maxBytes)
3229
4082
  break;
3230
4083
  kept.push(row);
@@ -3271,7 +4124,10 @@ function replicaRowKeyValue(value, keyField) {
3271
4124
  return key === null || key === undefined ? "" : String(key);
3272
4125
  }
3273
4126
  function replicaJSONSize(value) {
3274
- return new TextEncoder().encode(stableStringify(value)).byteLength;
4127
+ // Property ordering cannot change the encoded length. Native serialization
4128
+ // avoids recursively allocating sorted key arrays and intermediate strings.
4129
+ const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
4130
+ return new TextEncoder().encode(json).byteLength;
3275
4131
  }
3276
4132
  function applyKeyedPatch(previous, patch) {
3277
4133
  const rows = new Map();
@@ -3535,4 +4391,34 @@ function detectDeviceType(userAgent) {
3535
4391
  return "mobile";
3536
4392
  return "desktop";
3537
4393
  }
4394
+ /** Sign-in and account-only pages do not need reducer code loaded. */
4395
+ function deferredLocalExecutor(create) {
4396
+ let executor;
4397
+ let closed = false;
4398
+ const start = () => {
4399
+ if (closed)
4400
+ throw new Error("Local reducer executor is closed");
4401
+ return executor ??= create();
4402
+ };
4403
+ return {
4404
+ get ready() {
4405
+ try {
4406
+ return start().ready;
4407
+ }
4408
+ catch (error) {
4409
+ return Promise.reject(error);
4410
+ }
4411
+ },
4412
+ prepare: async (path) => start().prepare?.(path),
4413
+ execute: async (...args) => start().execute(...args),
4414
+ executeRead: async (...args) => {
4415
+ const instance = start();
4416
+ if (!instance.executeRead)
4417
+ throw new Error('Local executor does not support indexed reads');
4418
+ return instance.executeRead(...args);
4419
+ },
4420
+ replay: async (...args) => start().replay(...args),
4421
+ close: () => { closed = true; executor?.close(); },
4422
+ };
4423
+ }
3538
4424
  //# sourceMappingURL=index.js.map