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

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
6
  import { createReducerOutbox, } 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(),
@@ -117,20 +122,26 @@ const maxReplicaBatchOpens = 256;
117
122
  // normally settle in a few milliseconds.
118
123
  export class GonvexClient {
119
124
  url;
125
+ localTableBindings = new Map();
120
126
  localBinding;
121
127
  localExecutor;
122
128
  localStorage;
123
129
  localIdentity;
124
130
  localLane = Promise.resolve();
131
+ preparationLane = Promise.resolve();
125
132
  localReplayScheduled = false;
133
+ waitingLocalEdits = 0;
126
134
  replacingLocal = false;
127
135
  unsubscribeLocal;
128
136
  localCollectionClosers = [];
137
+ localCollectionKeys = new Set();
138
+ localExecutionTables = new Map();
129
139
  reducerRejectionHandlers = new Set();
130
140
  socket;
131
141
  handlers = new Map();
132
142
  querySubscriptions = new Map();
133
143
  replicaSubscriptions = new Map();
144
+ replicaMetadataListeners = new WeakSet();
134
145
  oneShotQueries = new Map();
135
146
  telemetryHandlers = new Set();
136
147
  pendingMessages = [];
@@ -179,6 +190,11 @@ export class GonvexClient {
179
190
  processedReplicaWatermarkRevision = 0;
180
191
  pendingReplicaTransactions = [];
181
192
  unsubscribeOutbox;
193
+ sharedOutboxStore;
194
+ unsubscribePeerOutbox;
195
+ unsubscribePeerReplica;
196
+ peerRefreshScheduled = false;
197
+ peerRefreshDirty = false;
182
198
  unsubscribeBrowserOnline;
183
199
  drainingOutbox = false;
184
200
  outboxDrainTimer;
@@ -206,17 +222,37 @@ export class GonvexClient {
206
222
  this.clientContract = options.clientContract ?? options.localRuntime?.clientContract;
207
223
  this.updateRequiredHandler = options.onUpdateRequired;
208
224
  this.localBinding = options.localRuntime;
209
- this.localExecutor = options.localRuntime?.create();
225
+ for (const table of this.localBinding?.tables ?? []) {
226
+ const references = (this.localBinding?.collections ?? []).filter(ref => ref.replica?.table === table
227
+ && !Object.keys(ref.replica.equalFilters ?? {}).length && !(ref.replica.excludeWhenSet?.length));
228
+ this.localTableBindings.set(table, { signatures: references.map(ref => querySubscriptionKey(ref, {})),
229
+ columns: [...new Set(references.flatMap(ref => ref.replica?.columns ?? []))] });
230
+ }
231
+ this.localExecutor = options.localRuntime ? deferredLocalExecutor(() => options.localRuntime.create()) : undefined;
210
232
  this.localStorage = options.localReplica?.storage;
211
233
  this.auth = authFromOptions(options);
212
234
  this.telemetryEnabled = options.telemetry === true;
213
235
  this.querySubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.querySubscriptionRetentionMs);
214
236
  this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
237
+ this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
215
238
  this.reducerOutbox = createReducerOutbox(options.outbox);
216
- this.replica = new LocalReplica(options.localReplica?.storage);
239
+ this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
217
240
  this.replicaView = createLocalReplicaView(this.replica);
218
- if (this.localExecutor)
219
- this.unsubscribeLocal = this.replica.subscribe(() => this.scheduleLocalReplay());
241
+ if (this.localExecutor) {
242
+ let executionVersion = this.replica.executionVersion();
243
+ this.unsubscribeLocal = this.replica.subscribe(() => {
244
+ const next = this.replica.executionVersion();
245
+ if (next === executionVersion)
246
+ return;
247
+ executionVersion = next;
248
+ this.scheduleLocalReplay();
249
+ });
250
+ }
251
+ this.unsubscribePeerOutbox = this.sharedOutboxStore?.subscribePeer?.(() => this.refreshPeerOutbox());
252
+ this.unsubscribePeerReplica = this.localStorage?.subscribePeer?.(scope => {
253
+ if (scope === this.replicaScope)
254
+ this.refreshPeerOutbox();
255
+ });
220
256
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
221
257
  void this.drainOutbox();
222
258
  });
@@ -269,38 +305,162 @@ export class GonvexClient {
269
305
  return;
270
306
  this.lastOnlineAtMs = saved.lastOnlineAtMs ?? 0;
271
307
  this.localIdentity = saved.identity;
308
+ void this.localExecutor?.ready.catch(() => undefined);
272
309
  await this.activateReplicaDirective(saved.directive);
273
310
  this.replica.setFreshness("offline");
274
311
  }
275
- ensureLocalCollections() {
276
- if (!this.localBinding || this.localCollectionClosers.length)
312
+ ensureLocalCollections(tables) {
313
+ if (!this.localBinding)
277
314
  return;
315
+ const needed = new Set(tables);
278
316
  for (const reference of this.localBinding.collections ?? []) {
279
- this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined));
317
+ if (!reference.replica || !needed.has(reference.replica.table))
318
+ continue;
319
+ const key = querySubscriptionKey(reference, {});
320
+ if (this.localCollectionKeys.has(key))
321
+ continue;
322
+ this.localCollectionKeys.add(key);
323
+ this.localCollectionClosers.push(this.subscribeReplicaTransport(reference, {}, () => undefined, true));
280
324
  }
281
325
  }
326
+ hydrateMissingLocalTable(error) {
327
+ if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
328
+ return;
329
+ // Worker errors preserve the public runtime name/message contract.
330
+ const table = /^Local replica for (.+?) is incomplete;/.exec(error.message)?.[1];
331
+ if (table)
332
+ this.ensureLocalCollections([table]);
333
+ }
282
334
  localSnapshot(includePending) {
283
- const snapshot = this.replica.snapshot();
335
+ const rows = this.replica.captureExecutionRows(includePending);
284
336
  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] ?? {}) };
337
+ for (const [table, binding] of this.localTableBindings) {
338
+ const complete = binding.signatures.some(signature => this.replica.windowIsComplete(signature));
339
+ tables[table] = { complete, columns: binding.columns, get rows() { return rows(table); } };
293
340
  }
294
341
  return { scope: this.replicaScope, tables };
295
342
  }
343
+ localReadCoverage() {
344
+ return this.replica.captureReadCoverage(Object.fromEntries([...this.localTableBindings].map(([table, binding]) => [table, {
345
+ key: this.localBinding.collections?.find(ref => ref.replica?.table === table)?.replica?.key ?? '_id',
346
+ complete: binding.signatures.some(signature => this.replica.windowIsComplete(signature)), columns: binding.columns,
347
+ }])));
348
+ }
349
+ isUncoveredLocalRead(error) {
350
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
351
+ return false;
352
+ const read = error.read;
353
+ if (!read || read.limit === 0)
354
+ return false;
355
+ const known = this.localReadCoverage()[read.table];
356
+ // Disk can recover evicted primary keys or a complete registered slice.
357
+ // It cannot prove a scan over data that has never been delivered. Queue
358
+ // that intent for replay instead of waiting for unrelated cache writes.
359
+ return !readIsCovered(known, read) && primaryReadKeys(read, known?.key ?? '_id') === undefined;
360
+ }
361
+ async executeLocal(path, args, snapshot, execution, pending = true, residentOnly = false) {
362
+ if (this.localBinding?.mode === 'portable') {
363
+ try {
364
+ const run = (source) => this.localExecutor.executeRead(path, args, source, execution);
365
+ let transaction;
366
+ try {
367
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run, true);
368
+ }
369
+ catch (error) {
370
+ if (residentOnly || !(error instanceof Error) || error.name !== 'IncompleteReplicaError' || this.isUncoveredLocalRead(error))
371
+ throw error;
372
+ transaction = await this.replica.withReadView(() => this.localReadCoverage(), pending, run);
373
+ }
374
+ // The read view already proved every read from the available replica
375
+ // windows or cached primary keys. Do not promote those bounded reads
376
+ // to whole-table subscriptions after a successful user edit. Missing
377
+ // coverage still opens its generated collection in the error path.
378
+ return transaction;
379
+ }
380
+ catch (error) {
381
+ // A speculative memory-only read may simply need IndexedDB. Its
382
+ // coordinated retry checks disk before deciding to request more data.
383
+ if (!residentOnly)
384
+ this.hydrateMissingLocalTable(error);
385
+ throw error;
386
+ }
387
+ }
388
+ if (!snapshot)
389
+ throw new Error('Legacy local executor requires a snapshot');
390
+ const known = this.localExecutionTables.get(path);
391
+ const input = known ? { ...snapshot, tables: Object.fromEntries(Object.entries(snapshot.tables).filter(([table]) => known.has(table))) } : snapshot;
392
+ let transaction;
393
+ try {
394
+ transaction = await this.localExecutor.execute(path, args, input, execution);
395
+ }
396
+ catch (error) {
397
+ this.hydrateMissingLocalTable(error);
398
+ if (!known || !(error instanceof Error) || error.name !== "IncompleteReplicaError")
399
+ throw error;
400
+ // A different argument, tenant or workflow can read different tables.
401
+ // Retry against the SAME captured snapshot, never newer mixed-version rows.
402
+ try {
403
+ transaction = await this.localExecutor.execute(path, args, snapshot, execution);
404
+ }
405
+ catch (retryError) {
406
+ this.hydrateMissingLocalTable(retryError);
407
+ throw retryError;
408
+ }
409
+ }
410
+ this.ensureLocalCollections(transaction.readTables);
411
+ this.localExecutionTables.set(path, new Set([
412
+ ...(known ?? []), ...transaction.readTables, ...transaction.patches.map(patch => patch.entity),
413
+ ]));
414
+ return transaction;
415
+ }
296
416
  inLocalLane(run) {
297
417
  const job = this.localLane.then(run);
298
418
  this.localLane = job.catch(() => undefined);
299
419
  return job;
300
420
  }
421
+ replaceLocalPredictions(commands) {
422
+ this.replica.replaceOptimistic(commands);
423
+ }
424
+ coordinateOutbox(lane, run) {
425
+ return this.sharedOutboxStore?.coordinate?.(this.outboxScope, lane, run) ?? run();
426
+ }
427
+ refreshPeerOutbox() {
428
+ if (this.manuallyClosed)
429
+ return;
430
+ this.peerRefreshDirty = true;
431
+ if (this.peerRefreshScheduled)
432
+ return;
433
+ this.peerRefreshScheduled = true;
434
+ void (async () => {
435
+ await this.outboxReady;
436
+ do {
437
+ this.peerRefreshDirty = false;
438
+ await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
439
+ const scope = this.outboxScope;
440
+ await this.replica.synchronizeStorage();
441
+ const entries = await this.reducerOutbox.list(scope);
442
+ if (this.manuallyClosed || scope !== this.outboxScope)
443
+ return;
444
+ this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
445
+ this.optimisticReducerIds.clear();
446
+ this.optimisticOutboxEntryIds.clear();
447
+ for (const entry of entries) {
448
+ this.optimisticOutboxEntryIds.set(entry.idempotencyKey, entry.id);
449
+ if (entry.patches?.length)
450
+ this.optimisticReducerIds.add(entry.idempotencyKey);
451
+ }
452
+ }));
453
+ } while (this.peerRefreshDirty && !this.manuallyClosed);
454
+ void this.drainOutbox();
455
+ })().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
456
+ }
301
457
  scheduleLocalReplay() {
302
458
  if (!this.localExecutor || this.replacingLocal || this.localReplayScheduled || this.manuallyClosed || !this.localIdentity)
303
459
  return;
460
+ // Restore and peer notifications discover durable intents explicitly. A
461
+ // table arriving at startup cannot invalidate a prediction that does not exist.
462
+ if (this.optimisticOutboxEntryIds.size === 0)
463
+ return;
304
464
  this.localReplayScheduled = true;
305
465
  queueMicrotask(() => {
306
466
  void (async () => {
@@ -314,21 +474,52 @@ export class GonvexClient {
314
474
  for (const listener of this.reducerRejectionHandlers)
315
475
  listener({ reducerId, path, error: message });
316
476
  }
317
- async rebaseLocalEntries() {
477
+ rebaseLocalEntries(afterRejection = false) {
478
+ return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
479
+ }
480
+ async rebaseLocalEntriesLocked(afterRejection = false) {
318
481
  if (!this.localExecutor || !this.localBinding || !this.localIdentity || !this.hasAuthoritativeReplicaScope)
319
482
  return;
483
+ // The authoritative drain validates the pending chain in order. Keep its
484
+ // predictions while it progresses, then rebase once it settles or loses
485
+ // connectivity instead of replaying N + (N-1) + ... local transactions.
486
+ if (!afterRejection && this.drainingOutbox && this.canSendReducerNow())
487
+ return;
488
+ await this.replica.synchronizeStorage();
320
489
  const scope = this.outboxScope;
321
490
  const replicaScope = this.replicaScope;
322
491
  const entries = await this.reducerOutbox.list(scope);
323
- const baseVersion = this.replica.version();
324
- const snapshot = this.localSnapshot(false);
492
+ if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
493
+ return;
494
+ if (entries.length === 0) {
495
+ this.replacingLocal = true;
496
+ try {
497
+ this.replaceLocalPredictions([]);
498
+ }
499
+ finally {
500
+ this.replacingLocal = false;
501
+ }
502
+ this.optimisticReducerIds.clear();
503
+ this.optimisticOutboxEntryIds.clear();
504
+ return;
505
+ }
506
+ const baseVersion = this.replica.executionVersion();
507
+ const needsExecution = entries.some(entry => entry.localExecution && entry.state !== "inflight" && entry.state !== "committed");
508
+ const snapshot = needsExecution && this.localBinding.mode !== 'portable' ? this.localSnapshot(false) : undefined;
509
+ const replayPatches = [];
325
510
  const commands = [];
326
511
  for (const entry of entries) {
512
+ // Predictions can wait; a fresh user intent must not sit behind the
513
+ // entire durable backlog. Publish only a complete, coherent rebase.
514
+ if (this.waitingLocalEdits > 0) {
515
+ setTimeout(() => this.scheduleLocalReplay(), 0);
516
+ return;
517
+ }
327
518
  let patches = entry.patches ?? [];
328
519
  if (entry.localExecution && entry.state !== "inflight" && entry.state !== "committed") {
329
520
  const execution = { ...entry.localExecution, scope: replicaScope, identity: this.localIdentity, artifactHash: this.localBinding.artifactHash };
330
521
  try {
331
- const transaction = await this.localExecutor.execute(entry.path, entry.args, snapshot, execution);
522
+ const transaction = await this.executeLocal(entry.path, entry.args, snapshot, execution, replayPatches);
332
523
  patches = transaction.patches;
333
524
  if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
334
525
  return;
@@ -344,16 +535,25 @@ export class GonvexClient {
344
535
  // Storage failures must abort rebasing, never masquerade as rejection.
345
536
  await this.reducerOutbox.updateLocal(entry.id, patches, execution);
346
537
  }
347
- applyLocalPatches(snapshot, patches);
538
+ if (snapshot)
539
+ applyLocalPatches(snapshot, patches);
540
+ for (const patch of patches)
541
+ replayPatches.push(patch.op === 'delete'
542
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: 'delete' }
543
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === 'upsert' ? 'insert' : patch.op, fields: patch.fields });
348
544
  commands.push({ commandId: entry.idempotencyKey, patches });
349
545
  }
350
546
  if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
351
547
  return;
352
- if (baseVersion !== this.replica.version())
353
- return this.rebaseLocalEntries();
548
+ if (baseVersion !== this.replica.executionVersion()) {
549
+ // A changing server base must not recursively occupy the local lane.
550
+ // Release it so user intents already queued can run before the retry.
551
+ setTimeout(() => this.scheduleLocalReplay(), 0);
552
+ return;
553
+ }
354
554
  this.replacingLocal = true;
355
555
  try {
356
- this.replica.replaceOptimistic(commands);
556
+ this.replaceLocalPredictions(commands);
357
557
  }
358
558
  finally {
359
559
  this.replacingLocal = false;
@@ -362,9 +562,10 @@ export class GonvexClient {
362
562
  this.optimisticOutboxEntryIds.clear();
363
563
  for (const entry of entries) {
364
564
  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
565
  }
566
+ for (const command of commands)
567
+ if (command.patches.length)
568
+ this.optimisticReducerIds.add(command.commandId);
368
569
  }
369
570
  async runLocalReducer(ref, args) {
370
571
  args = structuredClone(args);
@@ -373,7 +574,38 @@ export class GonvexClient {
373
574
  await this.replicaReady;
374
575
  if (!this.localExecutor || !this.localBinding)
375
576
  throw new Error("This reducer requires the generated Gonvex client with local execution enabled");
376
- return this.inLocalLane(async () => {
577
+ const reducerId = randomID();
578
+ const execution = this.localIdentity ? {
579
+ scope: this.replicaScope, commandId: reducerId, now: Date.now(),
580
+ intentEntropy: Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join(''),
581
+ artifactHash: this.localBinding.artifactHash, identity: structuredClone(this.localIdentity),
582
+ } : undefined;
583
+ let preparedResult;
584
+ let preparedFailure;
585
+ const prepared = this.preparationLane.then(async () => {
586
+ if (!execution || this.localBinding?.mode !== 'portable' || !this.hasAuthoritativeReplicaScope || this.manuallyClosed || this.updateRequired || admittedGeneration !== this.outboxScopeGeneration)
587
+ return;
588
+ if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
589
+ return;
590
+ const base = this.replica.executionVersion();
591
+ const pending = this.replica.pendingReducerPatches();
592
+ try {
593
+ const transaction = await this.executeLocal(ref.path, args, undefined, execution, pending, true);
594
+ if (base !== this.replica.executionVersion() || admittedGeneration !== this.outboxScopeGeneration || this.manuallyClosed)
595
+ return;
596
+ preparedResult = { transaction, base, pending: JSON.stringify(pending) };
597
+ }
598
+ catch (error) {
599
+ preparedFailure = error; /* Recheck coverage under coordinated admission. */
600
+ }
601
+ });
602
+ this.preparationLane = prepared.catch(() => undefined);
603
+ this.waitingLocalEdits += 1;
604
+ let entered = false;
605
+ return this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
606
+ entered = true;
607
+ this.waitingLocalEdits -= 1;
608
+ await prepared;
377
609
  if (admittedGeneration !== this.outboxScopeGeneration)
378
610
  throw new GonvexClientError("Session changed before local reducer execution", { code: "superseded" });
379
611
  if (!this.localIdentity || !this.hasAuthoritativeReplicaScope)
@@ -385,15 +617,51 @@ export class GonvexClient {
385
617
  if (this.clientContract && this.clientContract.offlineMaxAgeMs !== null && !this.canSendReducerNow() && (Date.now() - this.lastOnlineAtMs > this.clientContract.offlineMaxAgeMs || Date.now() < this.lastOnlineAtMs))
386
618
  throw new Error("Offline editing window expired. Reconnect before making more changes.");
387
619
  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
- };
620
+ if (!execution || execution.scope !== this.replicaScope)
621
+ throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
622
+ const sharedEntries = await this.reducerOutbox.list(scope);
623
+ const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
624
+ ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
625
+ : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
626
+ // A fully resident prediction reads the already published atomic replica.
627
+ // Unrelated snapshots may still be persisting; waiting for those here
628
+ // makes a small edit inherit their disk latency. The shared intent lock
629
+ // and journal still order every edit, and known peer changes must be
630
+ // reconciled before a resident prediction can be reused.
631
+ const reuseResident = preparedResult
632
+ && preparedResult.base === this.replica.executionVersion()
633
+ && preparedResult.pending === JSON.stringify(sharedPatches)
634
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
635
+ && !sharedEntries.some(entry => entry.state === "committed");
636
+ const uncoveredRead = this.isUncoveredLocalRead(preparedFailure)
637
+ && !this.peerRefreshDirty && !this.peerRefreshScheduled
638
+ && !sharedEntries.some(entry => entry.state === "committed");
639
+ if (!reuseResident && !uncoveredRead)
640
+ await this.replica.synchronizeStorage();
641
+ if (sharedEntries)
642
+ this.replaceLocalPredictions(sharedEntries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
643
+ const executionBaseVersion = this.replica.executionVersion();
394
644
  let transaction;
395
645
  try {
396
- transaction = await this.localExecutor.execute(ref.path, args, this.localSnapshot(true), execution);
646
+ if (preparedResult && preparedResult.base === executionBaseVersion && preparedResult.pending === JSON.stringify(sharedPatches)) {
647
+ transaction = preparedResult.transaction;
648
+ }
649
+ else if (this.localBinding.mode === 'portable') {
650
+ // A commit may invalidate preparation while the intent lock is being
651
+ // acquired. Read the newly published atomic replica again before
652
+ // joining disk work that arrived after synchronizeStorage completed.
653
+ try {
654
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true, true);
655
+ }
656
+ catch (error) {
657
+ if (!(error instanceof Error) || error.name !== 'IncompleteReplicaError')
658
+ throw error;
659
+ transaction = await this.executeLocal(ref.path, args, undefined, execution, sharedPatches ?? true);
660
+ }
661
+ }
662
+ else {
663
+ transaction = await this.executeLocal(ref.path, args, this.localSnapshot(true), execution, sharedPatches ?? true);
664
+ }
397
665
  }
398
666
  catch (error) {
399
667
  if (!(error instanceof Error) || error.name !== "IncompleteReplicaError")
@@ -404,6 +672,10 @@ export class GonvexClient {
404
672
  const patches = transaction?.patches ?? [];
405
673
  this.directOutboxReducerIds.add(reducerId);
406
674
  try {
675
+ // A visible prediction must already have a durable intent. Otherwise a
676
+ // reload immediately after paint can lose an offline edit. Local
677
+ // execution still avoids a server round trip; disk admission is bounded
678
+ // to this intent, rather than persisting the whole replica.
407
679
  const entry = await this.reducerOutbox.enqueue({
408
680
  scope, path: ref.path, args, idempotencyKey: reducerId, state: "pending",
409
681
  // All local intents form one causal chain, including reads of rows
@@ -423,13 +695,37 @@ export class GonvexClient {
423
695
  }
424
696
  return transaction ? transaction.result : { status: "queued", reducerId };
425
697
  }
698
+ catch (error) {
699
+ this.optimisticReducerIds.delete(reducerId);
700
+ this.replica.rejectCommand(reducerId);
701
+ throw error;
702
+ }
426
703
  finally {
427
704
  this.directOutboxReducerIds.delete(reducerId);
428
- this.scheduleLocalReplay();
705
+ // This transaction already executed against all earlier predictions.
706
+ // Appending it does not invalidate those predictions. Rebase only if
707
+ // the authoritative execution snapshot changed during execution.
708
+ // An uncovered read has no new data to replay against yet. Its
709
+ // hydration subscription schedules replay when coverage arrives;
710
+ // immediately retrying here only locks edits behind the same disk work.
711
+ if (this.replica.executionVersion() !== executionBaseVersion)
712
+ this.scheduleLocalReplay();
429
713
  void this.drainOutbox();
430
714
  }
715
+ })).catch(error => {
716
+ if (!entered)
717
+ this.waitingLocalEdits -= 1;
718
+ this.optimisticReducerIds.delete(reducerId);
719
+ this.replica.rejectCommand(reducerId);
720
+ throw error;
431
721
  });
432
722
  }
723
+ /** Preload code for a mounted control without reading data or executing an intent. */
724
+ prepareReducer(ref) {
725
+ if (ref.localExecution !== 1 || this.manuallyClosed)
726
+ return Promise.resolve();
727
+ return this.localExecutor?.prepare?.(ref.path) ?? Promise.resolve();
728
+ }
433
729
  /** The single normalized authoritative + optimistic application data store. */
434
730
  get localReplica() {
435
731
  return this.replicaView;
@@ -445,17 +741,31 @@ export class GonvexClient {
445
741
  replicaEntities(entity, ids) {
446
742
  return this.replica.entityBatch(entity, ids);
447
743
  }
744
+ /** Retain only the data an active view observes; cold rows remain on disk. */
745
+ retainReplicaEntities(entity, ids) { return this.replica.retainRows(entity, ids); }
746
+ retainReplicaWindow(signature) { return this.replica.retainWindow(signature); }
448
747
  /** Read rows and server-owned completeness for a persisted Replica Collection. */
449
748
  replicaCollectionState(ref, args = {}) {
450
749
  return this.replica.collectionState(this.replicaSignature(ref, args));
451
750
  }
452
751
  /** Run the generated Live Query plan over the bounded normalized cache. */
453
- offlineLiveQuery(ref, args = {}) {
752
+ offlineLiveQuery(ref, args = {}, options = {}) {
454
753
  if (!ref.live?.plan) {
455
754
  return { rows: [], completeness: "partial", supported: false, unsupportedOperator: "missingPlan" };
456
755
  }
457
756
  const queryArgs = isJsonRecord(args) ? args : {};
458
- return runOfflineLiveQuery(this.replica.entityRows(ref.live.entity), ref.live.plan, queryArgs, this.replica.entityCompleteness(ref.live.entity));
757
+ 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), {
758
+ context: {
759
+ ...(this.localIdentity?.member?.id ? { 'member.id': this.localIdentity.member.id } : {}),
760
+ ...(this.localIdentity?.auth.account?.id ? { 'account.id': this.localIdentity.auth.account.id } : {}),
761
+ ...(this.localIdentity?.tenant?.id ? { 'tenant.id': this.localIdentity.tenant.id } : {}),
762
+ },
763
+ relationRows: (table, where) => {
764
+ const coverage = this.replica.captureReadCoverage({ [table]: { key: '_id', complete: false } });
765
+ return { rows: this.replica.entityRows(table), completeness: this.replica.entityCompleteness(table) === 'complete'
766
+ && readIsCovered(coverage[table], { table, where: liveScopeCoverage(where, queryArgs) }) ? 'complete' : 'partial' };
767
+ },
768
+ });
459
769
  }
460
770
  /** Number of reducers waiting for a definitive server result. */
461
771
  async outboxCount() {
@@ -803,8 +1113,10 @@ export class GonvexClient {
803
1113
  this.authRetriedAfterError = false;
804
1114
  const directive = replicaDirectiveFromAuthResult(message.result);
805
1115
  const localIdentity = localIdentityFromAuthResult(message.result);
806
- if (localIdentity)
1116
+ if (localIdentity) {
807
1117
  this.localIdentity = localIdentity;
1118
+ void this.localExecutor?.ready.catch(() => undefined);
1119
+ }
808
1120
  if (!directive) {
809
1121
  this.authInFlight = false;
810
1122
  if (!this.auth.tenant) {
@@ -946,6 +1258,7 @@ export class GonvexClient {
946
1258
  this.unsubscribeLocal?.();
947
1259
  for (const close of this.localCollectionClosers.splice(0))
948
1260
  close();
1261
+ this.localCollectionKeys.clear();
949
1262
  this.localExecutor?.close();
950
1263
  this.cancelManagedAuthAttempt("Gonvex client was closed during authentication.");
951
1264
  if (isEphemeralOutboxScope(this.outboxScope)) {
@@ -982,6 +1295,8 @@ export class GonvexClient {
982
1295
  this.outboxDrainTimer = undefined;
983
1296
  }
984
1297
  this.unsubscribeOutbox();
1298
+ this.unsubscribePeerOutbox?.();
1299
+ this.unsubscribePeerReplica?.();
985
1300
  this.unsubscribeBrowserOnline?.();
986
1301
  this.handlers.clear();
987
1302
  this.querySubscriptions.clear();
@@ -999,6 +1314,7 @@ export class GonvexClient {
999
1314
  this.notifyConnectionState();
1000
1315
  this.connectionStateHandlers.clear();
1001
1316
  this.supportCommandHandlers.clear();
1317
+ this.replica.dispose();
1002
1318
  const closedError = new Error("Gonvex client is closed");
1003
1319
  for (const pending of this.pendingNativeErrors.splice(0))
1004
1320
  pending.reject(closedError);
@@ -1335,7 +1651,9 @@ export class GonvexClient {
1335
1651
  // could overwrite a result already accepted on the same connection.
1336
1652
  return subscription.revisionSocketGeneration !== this.socketGeneration;
1337
1653
  }
1338
- subscribeReplicaTransport(ref, args = {}, onMessage) {
1654
+ subscribeReplicaTransport(ref, args = {}, onMessage, metadataOnly = false) {
1655
+ if (metadataOnly)
1656
+ this.replicaMetadataListeners.add(onMessage);
1339
1657
  this.connect();
1340
1658
  const key = querySubscriptionKey(ref, args);
1341
1659
  const existing = this.replicaSubscriptions.get(key);
@@ -1345,10 +1663,14 @@ export class GonvexClient {
1345
1663
  existing.unsubscribeTimer = undefined;
1346
1664
  }
1347
1665
  existing.listeners.add(onMessage);
1666
+ // A reset may arrive during the retention grace period with no listeners.
1667
+ // Its reopen was correctly deferred; the next subscriber must resume it.
1668
+ if (!existing.isUpToDate && !existing.opening)
1669
+ this.sendReplicaOpen(existing);
1348
1670
  if (existing.lastMessage) {
1349
1671
  queueMicrotask(() => {
1350
1672
  if (existing.listeners.has(onMessage) && existing.lastMessage) {
1351
- onMessage(this.materializeReplicaMessage(existing, existing.lastMessage));
1673
+ onMessage(metadataOnly ? existing.lastMessage : this.materializeReplicaMessage(existing, existing.lastMessage));
1352
1674
  }
1353
1675
  });
1354
1676
  }
@@ -1386,12 +1708,16 @@ export class GonvexClient {
1386
1708
  return this.subscribeReplicaTransport(ref, args, onMessage);
1387
1709
  }
1388
1710
  /** Watch a bounded Replica Collection through the normalized Local Replica. */
1389
- watchReplica(ref, args = {}) {
1711
+ watchReplica(ref, args = {}, options = {}) {
1390
1712
  const key = querySubscriptionKey(ref, args);
1391
1713
  const updateHandlers = new Set();
1392
1714
  let latestError;
1393
1715
  let snapshotVersion = -1;
1394
1716
  let snapshotRows;
1717
+ const readRows = () => {
1718
+ const next = this.replica.liveQuerySnapshot(key).rows;
1719
+ return snapshotRows?.length === next.length && next.every((row, index) => row === snapshotRows[index]) ? snapshotRows : next;
1720
+ };
1395
1721
  let stateVersion = -1;
1396
1722
  let stateFreshness;
1397
1723
  let stateIsUpToDate;
@@ -1403,45 +1729,57 @@ export class GonvexClient {
1403
1729
  };
1404
1730
  // Keep the ReplicaSubscription as transport/reconciliation state only. The
1405
1731
  // 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;
1732
+ let stop;
1733
+ const start = () => {
1734
+ if (stop)
1735
+ return;
1426
1736
  snapshotVersion = -1;
1427
- snapshotRows = undefined;
1428
1737
  stateVersion = -1;
1429
- stateFreshness = undefined;
1430
- stateIsUpToDate = undefined;
1431
- snapshotState = undefined;
1432
- notify();
1433
- });
1738
+ const releaseRows = this.replica.retainWindow(key);
1739
+ const unsubscribeTransport = this.subscribeReplicaTransport(ref, args, (message) => {
1740
+ if (message.type === "replica.error") {
1741
+ latestError = new Error(message.error);
1742
+ notify();
1743
+ }
1744
+ else if (message.type === "replica.syncing" || message.type === "replica.reset") {
1745
+ latestError = undefined;
1746
+ notify();
1747
+ }
1748
+ else if (message.type === "replica.ready") {
1749
+ latestError = undefined;
1750
+ notify();
1751
+ }
1752
+ else if (message.type === "replica.snapshot") {
1753
+ latestError = undefined;
1754
+ }
1755
+ }, true);
1756
+ const unsubscribeReplica = this.replica.subscribe(notify);
1757
+ const unsubscribeScope = this.onSessionScopeChange(() => {
1758
+ latestError = undefined;
1759
+ snapshotVersion = -1;
1760
+ snapshotRows = undefined;
1761
+ stateVersion = -1;
1762
+ stateFreshness = undefined;
1763
+ stateIsUpToDate = undefined;
1764
+ snapshotState = undefined;
1765
+ notify();
1766
+ });
1767
+ stop = () => { unsubscribeTransport(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
1768
+ };
1769
+ // React can discard a render before subscribing. Such watches must own no resources.
1770
+ if (!options.deferStart)
1771
+ start();
1434
1772
  return {
1435
1773
  localReplicaResult: () => {
1436
1774
  if (latestError)
1437
1775
  throw latestError;
1438
1776
  if (!this.replica.hasLiveQuery(key))
1439
1777
  return undefined;
1440
- const version = this.replica.windowVersion(key);
1778
+ const version = this.replica.windowRowsVersion(key);
1441
1779
  if (snapshotVersion === version)
1442
1780
  return snapshotRows;
1443
1781
  snapshotVersion = version;
1444
- snapshotRows = this.replica.liveQuery(key).rows;
1782
+ snapshotRows = readRows();
1445
1783
  return snapshotRows;
1446
1784
  },
1447
1785
  localReplicaState: () => {
@@ -1459,7 +1797,12 @@ export class GonvexClient {
1459
1797
  stateVersion = version;
1460
1798
  stateFreshness = freshness;
1461
1799
  stateIsUpToDate = isUpToDate;
1462
- const state = this.replica.collectionState(key);
1800
+ const rowsVersion = this.replica.windowRowsVersion(key);
1801
+ if (snapshotVersion !== rowsVersion) {
1802
+ snapshotVersion = rowsVersion;
1803
+ snapshotRows = readRows();
1804
+ }
1805
+ const state = this.replica.collectionState(key, snapshotRows);
1463
1806
  snapshotState = {
1464
1807
  ...state,
1465
1808
  isUpToDate,
@@ -1475,6 +1818,7 @@ export class GonvexClient {
1475
1818
  isUpToDate: this.replicaSubscriptions.get(key)?.isUpToDate === true,
1476
1819
  }),
1477
1820
  onUpdate(handler) {
1821
+ start();
1478
1822
  if (releaseTimer) {
1479
1823
  clearTimeout(releaseTimer);
1480
1824
  releaseTimer = undefined;
@@ -1492,9 +1836,7 @@ export class GonvexClient {
1492
1836
  releaseTimer = undefined;
1493
1837
  if (updateHandlers.size > 0)
1494
1838
  return;
1495
- unsubscribeTransport();
1496
- unsubscribeReplica();
1497
- unsubscribeScope();
1839
+ stop?.();
1498
1840
  }, 0);
1499
1841
  };
1500
1842
  },
@@ -1505,7 +1847,7 @@ export class GonvexClient {
1505
1847
  * latest query result is retained only as the transport-shaped skeleton;
1506
1848
  * its row window is always rebuilt from LocalReplica membership/entities.
1507
1849
  */
1508
- watchLiveQuery(ref, args = {}) {
1850
+ watchLiveQuery(ref, args = {}, options = {}) {
1509
1851
  const key = querySubscriptionKey(ref, args);
1510
1852
  const updateHandlers = new Set();
1511
1853
  let transportResult;
@@ -1525,39 +1867,51 @@ export class GonvexClient {
1525
1867
  handler();
1526
1868
  });
1527
1869
  };
1528
- const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
1529
- if (message.type === "query.result") {
1530
- transportResult = message.result;
1870
+ let stop;
1871
+ const start = () => {
1872
+ if (stop)
1873
+ return;
1874
+ transportResult = undefined;
1875
+ snapshotToken = "";
1876
+ const releaseRows = this.replica.retainWindow(key);
1877
+ const unsubscribeQuery = this.subscribeLiveQuery(ref, args, (message) => {
1878
+ if (message.type === "query.result") {
1879
+ transportResult = message.result;
1880
+ transportGeneration += 1;
1881
+ latestError = undefined;
1882
+ // LocalReplica has already published its atomic window swap before
1883
+ // this callback is emitted, so this is the single initial UI wake-up.
1884
+ notify();
1885
+ }
1886
+ else if (message.type === "query.error") {
1887
+ latestError = new GonvexClientError(`Query ${ref.path} failed: ${message.error}`, {
1888
+ code: "server", path: ref.path, operation: "query",
1889
+ });
1890
+ notify();
1891
+ }
1892
+ });
1893
+ // During the initial query result, LocalReplica notifies before the
1894
+ // transport-shaped skeleton is installed above. Suppress that empty
1895
+ // intermediate wake-up; later transactions notify directly from the
1896
+ // normalized store.
1897
+ const unsubscribeReplica = this.replica.subscribe(() => {
1898
+ if (transportResult !== undefined || this.replica.hasLiveQuery(key))
1899
+ notify();
1900
+ });
1901
+ void this.replicaReady.then(() => notify());
1902
+ const unsubscribeScope = this.onSessionScopeChange(() => {
1903
+ transportResult = undefined;
1531
1904
  transportGeneration += 1;
1905
+ snapshotToken = "";
1906
+ snapshotResult = undefined;
1532
1907
  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
- 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
1908
  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
- });
1909
+ });
1910
+ stop = () => { unsubscribeQuery(); unsubscribeReplica(); unsubscribeScope(); releaseRows(); stop = undefined; };
1911
+ };
1912
+ // React can discard a render before subscribing. Such watches must own no resources.
1913
+ if (!options.deferStart)
1914
+ start();
1561
1915
  return {
1562
1916
  localLiveQueryResult: () => {
1563
1917
  if (latestError)
@@ -1592,6 +1946,7 @@ export class GonvexClient {
1592
1946
  return snapshotResult;
1593
1947
  },
1594
1948
  onUpdate(handler) {
1949
+ start();
1595
1950
  if (releaseTimer) {
1596
1951
  clearTimeout(releaseTimer);
1597
1952
  releaseTimer = undefined;
@@ -1609,9 +1964,7 @@ export class GonvexClient {
1609
1964
  releaseTimer = undefined;
1610
1965
  if (updateHandlers.size > 0)
1611
1966
  return;
1612
- unsubscribeQuery();
1613
- unsubscribeReplica();
1614
- unsubscribeScope();
1967
+ stop?.();
1615
1968
  }, 0);
1616
1969
  };
1617
1970
  },
@@ -1656,7 +2009,7 @@ export class GonvexClient {
1656
2009
  scope,
1657
2010
  };
1658
2011
  await this.replica.replaceWindow(window);
1659
- const snapshot = { ...message, result: this.replica.windowRows(subscription.key) };
2012
+ const snapshot = { ...message, result: [] };
1660
2013
  subscription.lastMessage = snapshot;
1661
2014
  this.emitReplicaMessage(subscription, snapshot, scope);
1662
2015
  return;
@@ -1667,12 +2020,20 @@ export class GonvexClient {
1667
2020
  return;
1668
2021
  this.clearReplicaRetry(subscription, true);
1669
2022
  raiseReplicaCursorFloor(subscription, message.cursor);
2023
+ const upserts = (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map(row => asReplicaRow(row));
2024
+ // Incremental integrity is safe only for full projected row images. Older
2025
+ // partial-row protocols keep the full normalized rehash on replica.ready.
2026
+ const canCarryHashes = prior?.hashes && subscription.columns?.length
2027
+ && upserts.every(row => subscription.columns.every(column => hasOwn(row, column)));
2028
+ const hashes = message.hashes ?? (canCarryHashes
2029
+ ? await applyReplicaHashDelta(prior.hashes, projectReplicaIntegrityRows(upserts, subscription.columns), message.deleted ?? [], prior?.key ?? "id")
2030
+ : undefined);
1670
2031
  await this.replica.applyWindowDelta({
1671
2032
  signature: subscription.key,
1672
2033
  kind: "replica",
1673
2034
  entity: subscription.entity,
1674
2035
  key: prior?.key ?? "id",
1675
- upserts: (message.upserts ?? []).filter((row) => asReplicaRow(row) !== undefined).map((row) => asReplicaRow(row)),
2036
+ upserts,
1676
2037
  deleted: message.deleted ?? [],
1677
2038
  completeness: prior?.completeness ?? "partial",
1678
2039
  source: "server",
@@ -1683,13 +2044,11 @@ export class GonvexClient {
1683
2044
  orderDirection: prior?.orderDirection,
1684
2045
  maxRows: prior?.maxRows,
1685
2046
  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,
2047
+ hashes,
1689
2048
  });
1690
2049
  const snapshot = {
1691
2050
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1692
- result: this.replica.windowRows(subscription.key), cursor: message.cursor,
2051
+ result: [], cursor: message.cursor,
1693
2052
  key: prior?.key ?? "id", mode: prior?.mode, orderBy: prior?.orderBy,
1694
2053
  orderDirection: prior?.orderDirection, maxRows: prior?.maxRows, maxBytes: prior?.maxBytes,
1695
2054
  };
@@ -1839,9 +2198,15 @@ export class GonvexClient {
1839
2198
  emitReplicaMessage(subscription, message, scope = this.replicaScope) {
1840
2199
  if (scope !== this.replicaScope || subscription.scope !== scope)
1841
2200
  return;
1842
- const outgoing = this.materializeReplicaMessage(subscription, message);
1843
- for (const listener of Array.from(subscription.listeners))
1844
- listener(outgoing);
2201
+ // Hook observers consume metadata only; their rows come from Local Replica.
2202
+ // Materialize a payload only when a public transport consumer needs it.
2203
+ let outgoing;
2204
+ for (const listener of Array.from(subscription.listeners)) {
2205
+ if (this.replicaMetadataListeners.has(listener))
2206
+ listener(message);
2207
+ else
2208
+ listener(outgoing ??= this.materializeReplicaMessage(subscription, message));
2209
+ }
1845
2210
  }
1846
2211
  materializeReplicaMessage(subscription, message) {
1847
2212
  if (message.type !== "replica.snapshot")
@@ -1887,7 +2252,7 @@ export class GonvexClient {
1887
2252
  subscription.isUpToDate = false;
1888
2253
  const message = {
1889
2254
  type: "replica.snapshot", id: subscription.id, path: subscription.path,
1890
- result: this.replica.windowRows(subscription.key), cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
2255
+ result: [], cursor: cached.cursor ?? { epoch: "cache", revision: 0 },
1891
2256
  key: cached.key, mode: cached.mode, orderBy: cached.orderBy,
1892
2257
  orderDirection: cached.orderDirection, maxRows: cached.maxRows, maxBytes: cached.maxBytes,
1893
2258
  };
@@ -2031,7 +2396,6 @@ export class GonvexClient {
2031
2396
  // and sends a second time with the same command ID.
2032
2397
  this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
2033
2398
  await this.outboxReady;
2034
- this.ensureLocalCollections();
2035
2399
  }
2036
2400
  quarantineReplicaScope() {
2037
2401
  this.localIdentity = undefined;
@@ -2059,6 +2423,7 @@ export class GonvexClient {
2059
2423
  }
2060
2424
  async restoreOutbox(scope, generation) {
2061
2425
  await this.replicaReady;
2426
+ await this.replica.synchronizeStorage();
2062
2427
  const entries = await this.reducerOutbox.loadAll(scope);
2063
2428
  if (this.manuallyClosed
2064
2429
  || generation !== this.outboxScopeGeneration
@@ -2095,8 +2460,11 @@ export class GonvexClient {
2095
2460
  void this.drainOutbox();
2096
2461
  }
2097
2462
  addOptimisticReducer(reducerId, patches, accepted = false) {
2098
- if (patches.length === 0 || this.optimisticReducerIds.has(reducerId))
2463
+ if (patches.length === 0) {
2464
+ this.optimisticReducerIds.delete(reducerId);
2465
+ this.replica.rejectCommand(reducerId);
2099
2466
  return;
2467
+ }
2100
2468
  this.optimisticReducerIds.add(reducerId);
2101
2469
  this.replica.applyOptimistic(reducerId, patches);
2102
2470
  }
@@ -2124,83 +2492,90 @@ export class GonvexClient {
2124
2492
  const drainScope = this.outboxScope;
2125
2493
  this.drainingOutbox = true;
2126
2494
  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);
2495
+ await this.coordinateOutbox("delivery", async () => {
2496
+ if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
2146
2497
  return;
2147
- }
2148
- if (!this.canSendReducerNow()) {
2149
- await this.reducerOutbox.markPending(entry.id);
2150
- 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
- });
2168
- }
2169
- else if ((entry.patches?.length ?? 0) > 0) {
2170
- await this.settleOptimisticReducer(entry.idempotencyKey);
2498
+ if (this.sharedOutboxStore)
2499
+ await this.reducerOutbox.recoverInflight?.(drainScope);
2500
+ while (!this.manuallyClosed && this.socket?.readyState === WebSocket.OPEN) {
2501
+ const scope = this.outboxScope;
2502
+ const entry = await this.inLocalLane(async () => {
2503
+ const next = await this.reducerOutbox.nextReady(scope, Date.now());
2504
+ if (next)
2505
+ await this.reducerOutbox.markInflight(next.id);
2506
+ return next;
2507
+ });
2508
+ if (!entry)
2509
+ return;
2510
+ if (scope !== this.outboxScope)
2511
+ return;
2512
+ if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
2513
+ // Scope recovery may observe an inflight row created by this live
2514
+ // process and reset it to pending under the assumption that a prior
2515
+ // process crashed. The foreground call still owns that command ID.
2516
+ // Restore the durable marker and wait for its real result instead of
2517
+ // registering a second response handler for the same command.
2518
+ await this.reducerOutbox.markInflight(entry.id);
2519
+ return;
2171
2520
  }
2172
- else {
2173
- await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2521
+ if (!this.canSendReducerNow()) {
2522
+ await this.reducerOutbox.markPending(entry.id);
2523
+ return;
2174
2524
  }
2175
- }
2176
- catch (error) {
2177
- if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
2525
+ await this.reducerOutbox.markInflight(entry.id);
2526
+ if (scope !== this.outboxScope)
2527
+ return;
2528
+ if (!this.canSendReducerNow()) {
2178
2529
  await this.reducerOutbox.markPending(entry.id);
2179
- this.requireClientUpdate(error.message);
2180
2530
  return;
2181
2531
  }
2182
- if (error instanceof GonvexClientError && error.code === "server") {
2532
+ try {
2533
+ 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);
2534
+ await this.reducerOutbox.markCommitted(entry.id);
2183
2535
  if (entry.localExecution && this.localExecutor) {
2184
2536
  await this.inLocalLane(async () => {
2185
2537
  await this.reducerOutbox.ack(entry.id);
2186
- if (scope === this.outboxScope) {
2538
+ if (scope === this.outboxScope)
2187
2539
  await this.rebaseLocalEntries();
2188
- this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2189
- }
2190
2540
  });
2191
2541
  }
2192
- else
2193
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2194
- continue;
2542
+ else if ((entry.patches?.length ?? 0) > 0) {
2543
+ await this.settleOptimisticReducer(entry.idempotencyKey);
2544
+ }
2545
+ else {
2546
+ await this.ackOptimisticReducer(entry.idempotencyKey, entry.id);
2547
+ }
2548
+ }
2549
+ catch (error) {
2550
+ if (error instanceof Error && /STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(error.message)) {
2551
+ await this.reducerOutbox.markPending(entry.id);
2552
+ this.requireClientUpdate(error.message);
2553
+ return;
2554
+ }
2555
+ if (error instanceof GonvexClientError && error.code === "server") {
2556
+ if (entry.localExecution && this.localExecutor) {
2557
+ await this.inLocalLane(async () => {
2558
+ await this.reducerOutbox.ack(entry.id);
2559
+ if (scope === this.outboxScope) {
2560
+ await this.rebaseLocalEntries(true);
2561
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2562
+ }
2563
+ });
2564
+ }
2565
+ else
2566
+ await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2567
+ continue;
2568
+ }
2569
+ await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
2570
+ this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
2571
+ return;
2195
2572
  }
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
2573
  }
2200
- }
2574
+ });
2201
2575
  }
2202
2576
  finally {
2203
2577
  this.drainingOutbox = false;
2578
+ this.scheduleLocalReplay();
2204
2579
  if (!this.manuallyClosed && drainScope !== this.outboxScope) {
2205
2580
  void this.drainOutbox();
2206
2581
  }
@@ -2514,8 +2889,11 @@ export class GonvexClient {
2514
2889
  return;
2515
2890
  }
2516
2891
  if (kind === "reducer" && message.type === "reducer.result") {
2517
- this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2518
2892
  const complete = () => {
2893
+ // A collection frame can advance the shared cursor before another
2894
+ // collection's rows and membership have arrived. Retire the local
2895
+ // prediction only after the commit's durable watermark barrier.
2896
+ this.replica.acknowledgeCommand(message.originCommandId, message.committedRevision);
2519
2897
  settle();
2520
2898
  this.emitTelemetryFromCall(kind, id, ref.path, "ok", clientSentAtMs, message.trace);
2521
2899
  resolve(message.result);
@@ -3160,15 +3538,33 @@ function replaceOfflineLiveQueryMetadata(result, path, offline) {
3160
3538
  const current = result[head];
3161
3539
  return { ...result, [head]: replaceOfflineLiveQueryMetadata(current ?? null, tail, offline) };
3162
3540
  }
3541
+ // Generated references and their plans are immutable module metadata. Retain
3542
+ // one canonical contract per reference, not a fresh sorted serialization for
3543
+ // every React watch/read. Weak keys do not retain replaced modules or callers.
3544
+ const subscriptionContracts = new WeakMap();
3163
3545
  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)}`;
3546
+ let cached = subscriptionContracts.get(ref);
3547
+ if (!cached || cached.scope !== ref.scope || cached.delivery !== ref.delivery || cached.live !== ref.live) {
3548
+ const contract = {
3549
+ scope: ref.scope ?? "tenant",
3550
+ delivery: ref.delivery ?? "oneShot",
3551
+ live: ref.live
3552
+ ? { entity: ref.live.entity, key: ref.live.key, resultPath: [...(ref.live.resultPath ?? [])], plan: ref.live.plan ?? null }
3553
+ : null,
3554
+ };
3555
+ cached = { scope: ref.scope, delivery: ref.delivery, live: ref.live, value: stableStringify(contract), keys: new Map() };
3556
+ subscriptionContracts.set(ref, cached);
3557
+ }
3558
+ const argumentsKey = `${ref.path}\u0000${stableStringify(args)}`;
3559
+ const previous = cached.keys.get(argumentsKey);
3560
+ if (previous !== undefined)
3561
+ return previous;
3562
+ const key = `${argumentsKey}\u0000${cached.value}`;
3563
+ // Search text and pagination may produce unbounded argument combinations.
3564
+ if (cached.keys.size >= 16)
3565
+ cached.keys.delete(cached.keys.keys().next().value);
3566
+ cached.keys.set(argumentsKey, key);
3567
+ return key;
3172
3568
  }
3173
3569
  function countPendingCalls(calls, kind) {
3174
3570
  let count = 0;
@@ -3202,6 +3598,9 @@ function stableStringify(value) {
3202
3598
  .join(",")}}`;
3203
3599
  }
3204
3600
  function utf8KeyCompare(left, right) {
3601
+ // ASCII keys have identical UTF-8 and JavaScript lexical ordering.
3602
+ if (!/[^\x00-\x7f]/.test(left) && !/[^\x00-\x7f]/.test(right))
3603
+ return left < right ? -1 : left > right ? 1 : 0;
3205
3604
  const leftBytes = new TextEncoder().encode(left);
3206
3605
  const rightBytes = new TextEncoder().encode(right);
3207
3606
  const length = Math.min(leftBytes.length, rightBytes.length);
@@ -3222,9 +3621,9 @@ function boundReplicaRows(rows, keyField, maxRows, maxBytes, orderBy, orderDirec
3222
3621
  const key = replicaRowKeyValue(row, keyField);
3223
3622
  if (!key || seen.has(key))
3224
3623
  continue;
3225
- const size = replicaJSONSize(row);
3226
3624
  if (maxRows && kept.length >= maxRows)
3227
3625
  break;
3626
+ const size = maxBytes ? replicaJSONSize(row) : 0;
3228
3627
  if (maxBytes && bytes + size > maxBytes)
3229
3628
  break;
3230
3629
  kept.push(row);
@@ -3271,7 +3670,10 @@ function replicaRowKeyValue(value, keyField) {
3271
3670
  return key === null || key === undefined ? "" : String(key);
3272
3671
  }
3273
3672
  function replicaJSONSize(value) {
3274
- return new TextEncoder().encode(stableStringify(value)).byteLength;
3673
+ // Property ordering cannot change the encoded length. Native serialization
3674
+ // avoids recursively allocating sorted key arrays and intermediate strings.
3675
+ const json = JSON.stringify(value).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3676
+ return new TextEncoder().encode(json).byteLength;
3275
3677
  }
3276
3678
  function applyKeyedPatch(previous, patch) {
3277
3679
  const rows = new Map();
@@ -3535,4 +3937,34 @@ function detectDeviceType(userAgent) {
3535
3937
  return "mobile";
3536
3938
  return "desktop";
3537
3939
  }
3940
+ /** Sign-in and account-only pages do not need reducer code loaded. */
3941
+ function deferredLocalExecutor(create) {
3942
+ let executor;
3943
+ let closed = false;
3944
+ const start = () => {
3945
+ if (closed)
3946
+ throw new Error("Local reducer executor is closed");
3947
+ return executor ??= create();
3948
+ };
3949
+ return {
3950
+ get ready() {
3951
+ try {
3952
+ return start().ready;
3953
+ }
3954
+ catch (error) {
3955
+ return Promise.reject(error);
3956
+ }
3957
+ },
3958
+ prepare: async (path) => start().prepare?.(path),
3959
+ execute: async (...args) => start().execute(...args),
3960
+ executeRead: async (...args) => {
3961
+ const instance = start();
3962
+ if (!instance.executeRead)
3963
+ throw new Error('Local executor does not support indexed reads');
3964
+ return instance.executeRead(...args);
3965
+ },
3966
+ replay: async (...args) => start().replay(...args),
3967
+ close: () => { closed = true; executor?.close(); },
3968
+ };
3969
+ }
3538
3970
  //# sourceMappingURL=index.js.map