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

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
@@ -3,7 +3,7 @@ import { applyReplicaHashDelta, replicaHashesDigest, replicaRowsHashes } from ".
3
3
  import { GonvexErrorReporter } from "./error-reporter.js";
4
4
  export { GonvexErrorReporter } from "./error-reporter.js";
5
5
  import { optimisticPatchesFromReference, } from "./optimistic.js";
6
- import { createReducerOutbox, } from "./outbox.js";
6
+ import { createReducerOutbox, DEFAULT_OUTBOX_MAX_BACKOFF_MS, outboxBackoffMs, outboxEntryIsLive, } from "./outbox.js";
7
7
  import { LocalReplica, } from "./local-replica.js";
8
8
  import { runOfflineLiveQuery, liveScopeCoverage } from "./query-expression.js";
9
9
  export * from "./error-reporter.js";
@@ -96,14 +96,28 @@ export class GonvexClientError extends Error {
96
96
  code;
97
97
  path;
98
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;
99
107
  constructor(message, options) {
100
108
  super(message);
101
109
  this.name = "GonvexClientError";
102
110
  this.code = options.code;
103
111
  this.path = options.path;
104
112
  this.operation = options.operation;
113
+ if (options.errorClass)
114
+ this.errorClass = options.errorClass;
115
+ if (options.retryable !== undefined)
116
+ this.retryable = options.retryable;
105
117
  }
106
118
  }
119
+ export const DEFAULT_OUTBOX_MAX_ATTEMPTS = 10;
120
+ export const DEFAULT_OUTBOX_RETRY_MAX_BACKOFF_MS = 60_000;
107
121
  export const DEFAULT_QUERY_TIMEOUT_MS = 20_000;
108
122
  export const DEFAULT_REDUCER_TIMEOUT_MS = 20_000;
109
123
  export const DEFAULT_ACTION_TIMEOUT_MS = 60_000;
@@ -137,6 +151,12 @@ export class GonvexClient {
137
151
  localCollectionKeys = new Set();
138
152
  localExecutionTables = new Map();
139
153
  reducerRejectionHandlers = new Set();
154
+ outboxRetry;
155
+ unauthenticatedRetries = 0;
156
+ intentsSnapshotValue = [];
157
+ intentListeners = new Set();
158
+ intentsRefreshRunning = false;
159
+ intentsRefreshDirty = false;
140
160
  socket;
141
161
  handlers = new Map();
142
162
  querySubscriptions = new Map();
@@ -197,6 +217,8 @@ export class GonvexClient {
197
217
  peerRefreshDirty = false;
198
218
  unsubscribeBrowserOnline;
199
219
  drainingOutbox = false;
220
+ /** A wake-up (timer, enqueue, reconnect) that arrived while a drain was running. */
221
+ outboxDrainRequested = false;
200
222
  outboxDrainTimer;
201
223
  sessionScopeHandlers = new Set();
202
224
  errorReporter;
@@ -236,6 +258,10 @@ export class GonvexClient {
236
258
  this.replicaSubscriptionRetentionMs = normalizeQuerySubscriptionRetentionMs(options.replicaSubscriptionRetentionMs);
237
259
  this.sharedOutboxStore = options.outbox?.store?.shared ? options.outbox.store : undefined;
238
260
  this.reducerOutbox = createReducerOutbox(options.outbox);
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
+ };
239
265
  this.replica = new LocalReplica(options.localReplica?.storage, { maxResidentRows: options.localReplica?.maxResidentRows, maxResidentBytes: options.localReplica?.maxResidentBytes });
240
266
  this.replicaView = createLocalReplicaView(this.replica);
241
267
  if (this.localExecutor) {
@@ -254,6 +280,7 @@ export class GonvexClient {
254
280
  this.refreshPeerOutbox();
255
281
  });
256
282
  this.unsubscribeOutbox = this.reducerOutbox.subscribe(() => {
283
+ this.refreshIntents();
257
284
  void this.drainOutbox();
258
285
  });
259
286
  if (typeof globalThis.addEventListener === "function") {
@@ -274,7 +301,7 @@ export class GonvexClient {
274
301
  this.outboxScope = initialScope;
275
302
  this.replicaScope = ["awaiting-server-scope", this.url, this.outboxEphemeralScope].join("\u0000");
276
303
  this.outboxScopeGeneration += 1;
277
- this.replicaReady = this.replica.activateScope(this.replicaScope, true);
304
+ this.setReplicaReady(this.replica.activateScope(this.replicaScope, true));
278
305
  this.outboxReady = Promise.resolve();
279
306
  this.timeouts = {
280
307
  queryTimeoutMs: options.timeouts?.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS,
@@ -292,11 +319,254 @@ export class GonvexClient {
292
319
  if (this.localBinding)
293
320
  this.outboxReady = this.restoreLocalSession(initialScope, this.outboxScopeGeneration);
294
321
  }
295
- /** 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
+ */
296
327
  onReducerRejection(listener) {
297
328
  this.reducerRejectionHandlers.add(listener);
298
329
  return () => this.reducerRejectionHandlers.delete(listener);
299
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
+ }
300
570
  async restoreLocalSession(scope, generation) {
301
571
  if (!this.localBinding || !scope.startsWith("identity\u0000"))
302
572
  return;
@@ -438,7 +708,7 @@ export class GonvexClient {
438
708
  await this.inLocalLane(() => this.coordinateOutbox("intent", async () => {
439
709
  const scope = this.outboxScope;
440
710
  await this.replica.synchronizeStorage();
441
- const entries = await this.reducerOutbox.list(scope);
711
+ const entries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
442
712
  if (this.manuallyClosed || scope !== this.outboxScope)
443
713
  return;
444
714
  this.replaceLocalPredictions(entries.filter(entry => entry.state !== "committed").map(entry => ({ commandId: entry.idempotencyKey, patches: entry.patches ?? [] })));
@@ -451,6 +721,7 @@ export class GonvexClient {
451
721
  }
452
722
  }));
453
723
  } while (this.peerRefreshDirty && !this.manuallyClosed);
724
+ this.refreshIntents();
454
725
  void this.drainOutbox();
455
726
  })().catch(() => undefined).finally(() => { this.peerRefreshScheduled = false; });
456
727
  }
@@ -471,8 +742,12 @@ export class GonvexClient {
471
742
  }
472
743
  reportLocalRejection(reducerId, path, error) {
473
744
  const message = reducerErrorMessage(error);
474
- for (const listener of this.reducerRejectionHandlers)
475
- 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
+ }
476
751
  }
477
752
  rebaseLocalEntries(afterRejection = false) {
478
753
  return this.coordinateOutbox("intent", () => this.rebaseLocalEntriesLocked(afterRejection));
@@ -488,7 +763,9 @@ export class GonvexClient {
488
763
  await this.replica.synchronizeStorage();
489
764
  const scope = this.outboxScope;
490
765
  const replicaScope = this.replicaScope;
491
- const entries = await this.reducerOutbox.list(scope);
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);
492
769
  if (scope !== this.outboxScope || replicaScope !== this.replicaScope)
493
770
  return;
494
771
  if (entries.length === 0) {
@@ -619,7 +896,7 @@ export class GonvexClient {
619
896
  const scope = this.outboxScope;
620
897
  if (!execution || execution.scope !== this.replicaScope)
621
898
  throw new GonvexClientError("Session changed before local reducer execution", { code: 'superseded' });
622
- const sharedEntries = await this.reducerOutbox.list(scope);
899
+ const sharedEntries = (await this.reducerOutbox.list(scope)).filter(outboxEntryIsLive);
623
900
  const sharedPatches = sharedEntries?.filter(entry => entry.state !== "committed").flatMap(entry => (entry.patches ?? []).map(patch => patch.op === "delete"
624
901
  ? { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: "delete" }
625
902
  : { entity: patch.entity ?? patch.collection, rowId: patch.rowId, op: patch.op === "upsert" ? "insert" : patch.op, fields: patch.fields }));
@@ -767,7 +1044,7 @@ export class GonvexClient {
767
1044
  },
768
1045
  });
769
1046
  }
770
- /** Number of reducers waiting for a definitive server result. */
1047
+ /** Number of reducers still queued for delivery (excludes failed and rejected intents). */
771
1048
  async outboxCount() {
772
1049
  await this.outboxReady;
773
1050
  return this.reducerOutbox.count(this.outboxScope);
@@ -2363,6 +2640,9 @@ export class GonvexClient {
2363
2640
  ? this.restoreOutbox(scope, generation)
2364
2641
  : this.restoreLocalSession(scope, generation);
2365
2642
  this.outboxReady = ready;
2643
+ // Never show the previous identity's intents under the new one.
2644
+ this.publishIntents([]);
2645
+ this.refreshIntents();
2366
2646
  return ready;
2367
2647
  }
2368
2648
  async activateReplicaDirective(directive) {
@@ -2385,7 +2665,7 @@ export class GonvexClient {
2385
2665
  this.resetReplicaScopeState();
2386
2666
  this.replicaScope = scope;
2387
2667
  this.hasAuthoritativeReplicaScope = true;
2388
- this.replicaReady = this.replica.activateScope(scope);
2668
+ this.setReplicaReady(this.replica.activateScope(scope));
2389
2669
  this.rotateSubscriptionScopes();
2390
2670
  const generation = this.outboxScopeGeneration;
2391
2671
  // Publish the recovery barrier before yielding to Replica storage. A
@@ -2397,6 +2677,14 @@ export class GonvexClient {
2397
2677
  this.outboxReady = this.restoreOutbox(this.outboxScope, generation);
2398
2678
  await this.outboxReady;
2399
2679
  }
2680
+ // Scope activation rejects once the replica is disposed (for example when
2681
+ // an auth error quarantines a client that was already closed). Callers that
2682
+ // await replicaReady still see the rejection; the stored promise itself must
2683
+ // not surface as an unhandled rejection that kills Node processes.
2684
+ setReplicaReady(ready) {
2685
+ this.replicaReady = ready;
2686
+ ready.catch(() => undefined);
2687
+ }
2400
2688
  quarantineReplicaScope() {
2401
2689
  this.localIdentity = undefined;
2402
2690
  // Keep the durable prior identity scope intact for an authorized future
@@ -2410,7 +2698,7 @@ export class GonvexClient {
2410
2698
  this.resetReplicaScopeState();
2411
2699
  this.replicaScope = scope;
2412
2700
  this.hasAuthoritativeReplicaScope = false;
2413
- this.replicaReady = this.replica.activateScope(scope, true);
2701
+ this.setReplicaReady(this.replica.activateScope(scope, true));
2414
2702
  this.rotateSubscriptionScopes();
2415
2703
  }
2416
2704
  rejectMissingReplicaDirective() {
@@ -2441,6 +2729,8 @@ export class GonvexClient {
2441
2729
  for (const entry of entries) {
2442
2730
  if (entry.localExecution && this.localExecutor)
2443
2731
  continue;
2732
+ if (!outboxEntryIsLive(entry))
2733
+ continue;
2444
2734
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
2445
2735
  await this.reducerOutbox.ack(entry.id);
2446
2736
  continue;
@@ -2457,6 +2747,7 @@ export class GonvexClient {
2457
2747
  // If this scope was installed after the socket authenticated, no reconnect
2458
2748
  // or new enqueue may occur to wake the queue. The await inside drainOutbox
2459
2749
  // yields until this restore promise resolves, then safely resumes it.
2750
+ this.refreshIntents();
2460
2751
  void this.drainOutbox();
2461
2752
  }
2462
2753
  addOptimisticReducer(reducerId, patches, accepted = false) {
@@ -2485,12 +2776,17 @@ export class GonvexClient {
2485
2776
  }
2486
2777
  async drainOutbox() {
2487
2778
  await this.outboxReady;
2488
- if (this.drainingOutbox
2489
- || this.manuallyClosed
2490
- || !this.canSendReducerNow())
2779
+ if (this.drainingOutbox) {
2780
+ // The running drain may already be past the entry this wake-up is for
2781
+ // (for example a short backoff timer firing before the drain returned).
2782
+ this.outboxDrainRequested = true;
2783
+ return;
2784
+ }
2785
+ if (this.manuallyClosed || !this.canSendReducerNow())
2491
2786
  return;
2492
2787
  const drainScope = this.outboxScope;
2493
2788
  this.drainingOutbox = true;
2789
+ this.outboxDrainRequested = false;
2494
2790
  try {
2495
2791
  await this.coordinateOutbox("delivery", async () => {
2496
2792
  if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
@@ -2501,12 +2797,18 @@ export class GonvexClient {
2501
2797
  const scope = this.outboxScope;
2502
2798
  const entry = await this.inLocalLane(async () => {
2503
2799
  const next = await this.reducerOutbox.nextReady(scope, Date.now());
2504
- if (next)
2505
- await this.reducerOutbox.markInflight(next.id);
2800
+ // A concurrent discard (this or another tab) fences the entry; a
2801
+ // refused transition means it must not be sent.
2802
+ if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
2803
+ return null;
2506
2804
  return next;
2507
2805
  });
2508
- if (!entry)
2806
+ if (entry === null)
2807
+ continue;
2808
+ if (!entry) {
2809
+ await this.scheduleNextOutboxAttempt(scope);
2509
2810
  return;
2811
+ }
2510
2812
  if (scope !== this.outboxScope)
2511
2813
  return;
2512
2814
  if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
@@ -2522,7 +2824,8 @@ export class GonvexClient {
2522
2824
  await this.reducerOutbox.markPending(entry.id);
2523
2825
  return;
2524
2826
  }
2525
- await this.reducerOutbox.markInflight(entry.id);
2827
+ if ((await this.reducerOutbox.markInflight(entry.id)) === false)
2828
+ continue;
2526
2829
  if (scope !== this.outboxScope)
2527
2830
  return;
2528
2831
  if (!this.canSendReducerNow()) {
@@ -2531,6 +2834,7 @@ export class GonvexClient {
2531
2834
  }
2532
2835
  try {
2533
2836
  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);
2837
+ this.unauthenticatedRetries = 0;
2534
2838
  await this.reducerOutbox.markCommitted(entry.id);
2535
2839
  if (entry.localExecution && this.localExecutor) {
2536
2840
  await this.inLocalLane(async () => {
@@ -2547,27 +2851,34 @@ export class GonvexClient {
2547
2851
  }
2548
2852
  }
2549
2853
  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") {
2854
+ const disposition = deliveryErrorClass(error);
2855
+ if (disposition === "rejected") {
2856
+ // Keep today's rollback and rebase, but record the rejection
2857
+ // durably instead of deleting the intent without a trace.
2556
2858
  if (entry.localExecution && this.localExecutor) {
2557
2859
  await this.inLocalLane(async () => {
2558
- await this.reducerOutbox.ack(entry.id);
2860
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2559
2861
  if (scope === this.outboxScope) {
2560
2862
  await this.rebaseLocalEntries(true);
2561
2863
  this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2562
2864
  }
2563
2865
  });
2564
2866
  }
2565
- else
2566
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2867
+ else {
2868
+ this.optimisticReducerIds.delete(entry.idempotencyKey);
2869
+ this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
2870
+ this.replica.rejectCommand(entry.idempotencyKey);
2871
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2872
+ if (scope === this.outboxScope)
2873
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2874
+ }
2567
2875
  continue;
2568
2876
  }
2569
- await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
2570
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
2877
+ const outcome = await this.recordDeliveryFailure(entry, error, disposition);
2878
+ // A parked intent keeps its prediction but no longer blocks the
2879
+ // chain; later intents proceed and the server validates them.
2880
+ if (outcome === "parked")
2881
+ continue;
2571
2882
  return;
2572
2883
  }
2573
2884
  }
@@ -2576,11 +2887,74 @@ export class GonvexClient {
2576
2887
  finally {
2577
2888
  this.drainingOutbox = false;
2578
2889
  this.scheduleLocalReplay();
2579
- if (!this.manuallyClosed && drainScope !== this.outboxScope) {
2890
+ const requested = this.outboxDrainRequested;
2891
+ this.outboxDrainRequested = false;
2892
+ if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
2580
2893
  void this.drainOutbox();
2581
2894
  }
2582
2895
  }
2583
2896
  }
2897
+ /**
2898
+ * Record a non-rejection delivery failure and arm the next attempt.
2899
+ * - update_required: keep the intent pending and stop for an app update.
2900
+ * - unauthenticated: keep the intent, re-authenticate, retry with backoff
2901
+ * that never spends the retry budget.
2902
+ * - network: connectivity loss; retried on reconnect without spending budget.
2903
+ * - transient (and timeouts): exponential backoff, parked as `failed` once
2904
+ * the retry budget is spent.
2905
+ */
2906
+ async recordDeliveryFailure(entry, error, disposition) {
2907
+ const message = reducerErrorMessage(error);
2908
+ if (disposition === "update_required") {
2909
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
2910
+ this.requireClientUpdate(message);
2911
+ return "update_required";
2912
+ }
2913
+ if (disposition === "unauthenticated") {
2914
+ const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
2915
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
2916
+ this.requestReauthentication();
2917
+ this.scheduleOutboxDrain(delay);
2918
+ return "retrying";
2919
+ }
2920
+ const counted = disposition === "transient";
2921
+ const updated = await this.reducerOutbox.fail(entry.id, message, {
2922
+ errorClass: disposition,
2923
+ countAttempt: counted,
2924
+ maxAttempts: this.outboxRetry.maxAttempts,
2925
+ maxBackoffMs: this.outboxRetry.maxBackoffMs,
2926
+ ...(counted ? {} : { delayMs: 1_000 }),
2927
+ });
2928
+ if (updated && updated.state === "failed")
2929
+ return "parked";
2930
+ const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
2931
+ this.scheduleOutboxDrain(delay);
2932
+ return "retrying";
2933
+ }
2934
+ /**
2935
+ * Arm a timer for the earliest backed-off entry. Timer clocks and
2936
+ * `Date.now()` can disagree by a millisecond, so a wake-up may find its
2937
+ * entry not quite due; without this the queue would wait for an unrelated
2938
+ * event to resume.
2939
+ */
2940
+ async scheduleNextOutboxAttempt(scope) {
2941
+ const now = Date.now();
2942
+ let next = Infinity;
2943
+ for (const entry of await this.reducerOutbox.list(scope)) {
2944
+ if (entry.state === "pending" && entry.nextAttemptAt > now)
2945
+ next = Math.min(next, entry.nextAttemptAt);
2946
+ }
2947
+ if (Number.isFinite(next) && scope === this.outboxScope)
2948
+ this.scheduleOutboxDrain(next - now + 1);
2949
+ }
2950
+ /** Re-send auth on the open socket so a lost tenant session is restored. */
2951
+ requestReauthentication() {
2952
+ if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
2953
+ return;
2954
+ if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
2955
+ return;
2956
+ this.sendAuth(false);
2957
+ }
2584
2958
  scheduleOutboxDrain(delay) {
2585
2959
  if (this.manuallyClosed)
2586
2960
  return;
@@ -2620,7 +2994,6 @@ export class GonvexClient {
2620
2994
  }
2621
2995
  this.directOutboxReducerIds.add(reducerId);
2622
2996
  let entryId;
2623
- let entryAttempts = 0;
2624
2997
  try {
2625
2998
  const scope = this.outboxScope;
2626
2999
  const entry = await this.reducerOutbox.enqueue({
@@ -2633,7 +3006,6 @@ export class GonvexClient {
2633
3006
  state: "inflight",
2634
3007
  });
2635
3008
  entryId = entry.id;
2636
- entryAttempts = entry.attempts;
2637
3009
  if (this.manuallyClosed) {
2638
3010
  await this.reducerOutbox.ack(entry.id);
2639
3011
  throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
@@ -2661,14 +3033,13 @@ export class GonvexClient {
2661
3033
  return result;
2662
3034
  }
2663
3035
  catch (error) {
2664
- if (isQueueableReducerError(error) && options.offline === "queue") {
3036
+ const disposition = deliveryErrorClass(error);
3037
+ if (disposition !== "rejected" && options.offline === "queue") {
2665
3038
  const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
2666
3039
  if (queuedEntryId !== undefined) {
2667
- await this.reducerOutbox.fail(queuedEntryId, reducerErrorMessage(error));
2668
- // `fail` deliberately records backoff, but it does not own the
2669
- // client's timer. The foreground queueable path must schedule the
2670
- // next deterministic drain just like the background drain path.
2671
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entryAttempts + 1))));
3040
+ // Records backoff (or parks the entry) and arms the client's timer,
3041
+ // exactly like the background drain path.
3042
+ await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
2672
3043
  }
2673
3044
  return { status: "queued", reducerId };
2674
3045
  }
@@ -2917,7 +3288,11 @@ export class GonvexClient {
2917
3288
  if (kind === "reducer" && message.type === "reducer.error") {
2918
3289
  settle();
2919
3290
  this.emitTelemetryFromCall(kind, id, ref.path, "error", clientSentAtMs, message.trace, message.error);
2920
- reject(new GonvexClientError(message.error, { code: "server", path: ref.path, operation: kind }));
3291
+ const errorClass = reducerErrorClassFromMessage(message);
3292
+ reject(new GonvexClientError(message.error, {
3293
+ code: "server", path: ref.path, operation: kind,
3294
+ ...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
3295
+ }));
2921
3296
  }
2922
3297
  if (kind === "action" && message.type === "action.result") {
2923
3298
  const complete = () => {
@@ -3574,9 +3949,96 @@ function countPendingCalls(calls, kind) {
3574
3949
  }
3575
3950
  return count;
3576
3951
  }
3577
- function isQueueableReducerError(error) {
3578
- return error instanceof GonvexClientError
3579
- && (error.code === "disconnected" || error.code === "timeout");
3952
+ /**
3953
+ * How the outbox treats one failed delivery. Runtimes before
3954
+ * 0.5.2-staging.15 send no class: their server errors stay rejections, as
3955
+ * before, except the two cases the legacy runtime could only express in prose.
3956
+ */
3957
+ function deliveryErrorClass(error) {
3958
+ if (!(error instanceof GonvexClientError))
3959
+ return "transient";
3960
+ switch (error.code) {
3961
+ case "server": return error.errorClass ?? "rejected";
3962
+ case "timeout": return "transient";
3963
+ default: return "network";
3964
+ }
3965
+ }
3966
+ const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
3967
+ function reducerErrorClassFromMessage(message) {
3968
+ if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
3969
+ return message.class;
3970
+ }
3971
+ // Legacy runtimes: the only structured signals were these literal messages.
3972
+ if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
3973
+ return "update_required";
3974
+ if (message.error === legacyUnauthenticatedReducerError)
3975
+ return "unauthenticated";
3976
+ return undefined;
3977
+ }
3978
+ function positiveOr(value, fallback) {
3979
+ return typeof value === "number" && value > 0 ? value : fallback;
3980
+ }
3981
+ function summarizeArgs(args) {
3982
+ let text;
3983
+ try {
3984
+ text = JSON.stringify(args) ?? "";
3985
+ }
3986
+ catch {
3987
+ text = String(args);
3988
+ }
3989
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
3990
+ }
3991
+ function outboxIntentFromEntry(entry) {
3992
+ const entities = new Map();
3993
+ for (const patch of entry.patches ?? []) {
3994
+ const entity = patch.entity ?? patch.collection;
3995
+ if (!entity)
3996
+ continue;
3997
+ entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
3998
+ }
3999
+ return {
4000
+ id: entry.idempotencyKey,
4001
+ entryId: entry.id,
4002
+ reducer: entry.path,
4003
+ state: entry.state,
4004
+ attempts: entry.attempts,
4005
+ ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
4006
+ ...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
4007
+ createdAt: entry.createdAt,
4008
+ nextAttemptAt: entry.nextAttemptAt,
4009
+ ...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
4010
+ args: entry.args,
4011
+ argsSummary: summarizeArgs(entry.args),
4012
+ entities: [...entities.values()],
4013
+ };
4014
+ }
4015
+ function sameIntents(left, right) {
4016
+ if (left.length !== right.length)
4017
+ return false;
4018
+ for (let index = 0; index < left.length; index += 1) {
4019
+ const a = left[index], b = right[index];
4020
+ if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
4021
+ || a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
4022
+ || a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
4023
+ || a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
4024
+ return false;
4025
+ }
4026
+ return true;
4027
+ }
4028
+ /** Row status from a list of intents: failed > rejected > syncing. */
4029
+ export function entityStatusFromIntents(intents, entity, id) {
4030
+ let status;
4031
+ for (const intent of intents) {
4032
+ if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
4033
+ continue;
4034
+ if (intent.state === "failed")
4035
+ return "failed";
4036
+ if (intent.state === "rejected")
4037
+ status = "rejected";
4038
+ else if (intent.state !== "committed" && status === undefined)
4039
+ status = "syncing";
4040
+ }
4041
+ return status;
3580
4042
  }
3581
4043
  function reducerErrorMessage(error) {
3582
4044
  return error instanceof Error ? error.message : String(error);