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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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") {
@@ -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) {
@@ -2441,6 +2721,8 @@ export class GonvexClient {
2441
2721
  for (const entry of entries) {
2442
2722
  if (entry.localExecution && this.localExecutor)
2443
2723
  continue;
2724
+ if (!outboxEntryIsLive(entry))
2725
+ continue;
2444
2726
  if (entry.state === "committed" && (entry.patches?.length ?? 0) === 0) {
2445
2727
  await this.reducerOutbox.ack(entry.id);
2446
2728
  continue;
@@ -2457,6 +2739,7 @@ export class GonvexClient {
2457
2739
  // If this scope was installed after the socket authenticated, no reconnect
2458
2740
  // or new enqueue may occur to wake the queue. The await inside drainOutbox
2459
2741
  // yields until this restore promise resolves, then safely resumes it.
2742
+ this.refreshIntents();
2460
2743
  void this.drainOutbox();
2461
2744
  }
2462
2745
  addOptimisticReducer(reducerId, patches, accepted = false) {
@@ -2485,12 +2768,17 @@ export class GonvexClient {
2485
2768
  }
2486
2769
  async drainOutbox() {
2487
2770
  await this.outboxReady;
2488
- if (this.drainingOutbox
2489
- || this.manuallyClosed
2490
- || !this.canSendReducerNow())
2771
+ if (this.drainingOutbox) {
2772
+ // The running drain may already be past the entry this wake-up is for
2773
+ // (for example a short backoff timer firing before the drain returned).
2774
+ this.outboxDrainRequested = true;
2775
+ return;
2776
+ }
2777
+ if (this.manuallyClosed || !this.canSendReducerNow())
2491
2778
  return;
2492
2779
  const drainScope = this.outboxScope;
2493
2780
  this.drainingOutbox = true;
2781
+ this.outboxDrainRequested = false;
2494
2782
  try {
2495
2783
  await this.coordinateOutbox("delivery", async () => {
2496
2784
  if (this.manuallyClosed || drainScope !== this.outboxScope || !this.canSendReducerNow())
@@ -2501,12 +2789,18 @@ export class GonvexClient {
2501
2789
  const scope = this.outboxScope;
2502
2790
  const entry = await this.inLocalLane(async () => {
2503
2791
  const next = await this.reducerOutbox.nextReady(scope, Date.now());
2504
- if (next)
2505
- await this.reducerOutbox.markInflight(next.id);
2792
+ // A concurrent discard (this or another tab) fences the entry; a
2793
+ // refused transition means it must not be sent.
2794
+ if (next && (await this.reducerOutbox.markInflight(next.id)) === false)
2795
+ return null;
2506
2796
  return next;
2507
2797
  });
2508
- if (!entry)
2798
+ if (entry === null)
2799
+ continue;
2800
+ if (!entry) {
2801
+ await this.scheduleNextOutboxAttempt(scope);
2509
2802
  return;
2803
+ }
2510
2804
  if (scope !== this.outboxScope)
2511
2805
  return;
2512
2806
  if (this.directOutboxReducerIds.has(entry.idempotencyKey)) {
@@ -2522,7 +2816,8 @@ export class GonvexClient {
2522
2816
  await this.reducerOutbox.markPending(entry.id);
2523
2817
  return;
2524
2818
  }
2525
- await this.reducerOutbox.markInflight(entry.id);
2819
+ if ((await this.reducerOutbox.markInflight(entry.id)) === false)
2820
+ continue;
2526
2821
  if (scope !== this.outboxScope)
2527
2822
  return;
2528
2823
  if (!this.canSendReducerNow()) {
@@ -2531,6 +2826,7 @@ export class GonvexClient {
2531
2826
  }
2532
2827
  try {
2533
2828
  await this.call("reducer", { kind: "reducer", path: entry.path, receiptPath: entry.receiptPath, ...(entry.localExecution ? { artifactHash: entry.localExecution.artifactHash, intentEntropy: entry.localExecution.intentEntropy } : {}) }, entry.args, this.timeouts.reducerTimeoutMs, entry.idempotencyKey, entry.idempotencyKey);
2829
+ this.unauthenticatedRetries = 0;
2534
2830
  await this.reducerOutbox.markCommitted(entry.id);
2535
2831
  if (entry.localExecution && this.localExecutor) {
2536
2832
  await this.inLocalLane(async () => {
@@ -2547,27 +2843,34 @@ export class GonvexClient {
2547
2843
  }
2548
2844
  }
2549
2845
  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") {
2846
+ const disposition = deliveryErrorClass(error);
2847
+ if (disposition === "rejected") {
2848
+ // Keep today's rollback and rebase, but record the rejection
2849
+ // durably instead of deleting the intent without a trace.
2556
2850
  if (entry.localExecution && this.localExecutor) {
2557
2851
  await this.inLocalLane(async () => {
2558
- await this.reducerOutbox.ack(entry.id);
2852
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2559
2853
  if (scope === this.outboxScope) {
2560
2854
  await this.rebaseLocalEntries(true);
2561
2855
  this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2562
2856
  }
2563
2857
  });
2564
2858
  }
2565
- else
2566
- await this.rejectOptimisticReducer(entry.idempotencyKey, entry.id);
2859
+ else {
2860
+ this.optimisticReducerIds.delete(entry.idempotencyKey);
2861
+ this.optimisticOutboxEntryIds.delete(entry.idempotencyKey);
2862
+ this.replica.rejectCommand(entry.idempotencyKey);
2863
+ await this.reducerOutbox.reject(entry.id, reducerErrorMessage(error));
2864
+ if (scope === this.outboxScope)
2865
+ this.reportLocalRejection(entry.idempotencyKey, entry.path, error);
2866
+ }
2567
2867
  continue;
2568
2868
  }
2569
- await this.reducerOutbox.fail(entry.id, reducerErrorMessage(error));
2570
- this.scheduleOutboxDrain(Math.min(30_000, 1_000 * (2 ** (entry.attempts + 1))));
2869
+ const outcome = await this.recordDeliveryFailure(entry, error, disposition);
2870
+ // A parked intent keeps its prediction but no longer blocks the
2871
+ // chain; later intents proceed and the server validates them.
2872
+ if (outcome === "parked")
2873
+ continue;
2571
2874
  return;
2572
2875
  }
2573
2876
  }
@@ -2576,11 +2879,74 @@ export class GonvexClient {
2576
2879
  finally {
2577
2880
  this.drainingOutbox = false;
2578
2881
  this.scheduleLocalReplay();
2579
- if (!this.manuallyClosed && drainScope !== this.outboxScope) {
2882
+ const requested = this.outboxDrainRequested;
2883
+ this.outboxDrainRequested = false;
2884
+ if (!this.manuallyClosed && (drainScope !== this.outboxScope || requested)) {
2580
2885
  void this.drainOutbox();
2581
2886
  }
2582
2887
  }
2583
2888
  }
2889
+ /**
2890
+ * Record a non-rejection delivery failure and arm the next attempt.
2891
+ * - update_required: keep the intent pending and stop for an app update.
2892
+ * - unauthenticated: keep the intent, re-authenticate, retry with backoff
2893
+ * that never spends the retry budget.
2894
+ * - network: connectivity loss; retried on reconnect without spending budget.
2895
+ * - transient (and timeouts): exponential backoff, parked as `failed` once
2896
+ * the retry budget is spent.
2897
+ */
2898
+ async recordDeliveryFailure(entry, error, disposition) {
2899
+ const message = reducerErrorMessage(error);
2900
+ if (disposition === "update_required") {
2901
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: 0 });
2902
+ this.requireClientUpdate(message);
2903
+ return "update_required";
2904
+ }
2905
+ if (disposition === "unauthenticated") {
2906
+ const delay = outboxBackoffMs(this.unauthenticatedRetries++, this.outboxRetry.maxBackoffMs);
2907
+ await this.reducerOutbox.fail(entry.id, message, { errorClass: disposition, countAttempt: false, delayMs: delay });
2908
+ this.requestReauthentication();
2909
+ this.scheduleOutboxDrain(delay);
2910
+ return "retrying";
2911
+ }
2912
+ const counted = disposition === "transient";
2913
+ const updated = await this.reducerOutbox.fail(entry.id, message, {
2914
+ errorClass: disposition,
2915
+ countAttempt: counted,
2916
+ maxAttempts: this.outboxRetry.maxAttempts,
2917
+ maxBackoffMs: this.outboxRetry.maxBackoffMs,
2918
+ ...(counted ? {} : { delayMs: 1_000 }),
2919
+ });
2920
+ if (updated && updated.state === "failed")
2921
+ return "parked";
2922
+ const delay = updated ? Math.max(0, updated.nextAttemptAt - Date.now()) : DEFAULT_OUTBOX_MAX_BACKOFF_MS;
2923
+ this.scheduleOutboxDrain(delay);
2924
+ return "retrying";
2925
+ }
2926
+ /**
2927
+ * Arm a timer for the earliest backed-off entry. Timer clocks and
2928
+ * `Date.now()` can disagree by a millisecond, so a wake-up may find its
2929
+ * entry not quite due; without this the queue would wait for an unrelated
2930
+ * event to resume.
2931
+ */
2932
+ async scheduleNextOutboxAttempt(scope) {
2933
+ const now = Date.now();
2934
+ let next = Infinity;
2935
+ for (const entry of await this.reducerOutbox.list(scope)) {
2936
+ if (entry.state === "pending" && entry.nextAttemptAt > now)
2937
+ next = Math.min(next, entry.nextAttemptAt);
2938
+ }
2939
+ if (Number.isFinite(next) && scope === this.outboxScope)
2940
+ this.scheduleOutboxDrain(next - now + 1);
2941
+ }
2942
+ /** Re-send auth on the open socket so a lost tenant session is restored. */
2943
+ requestReauthentication() {
2944
+ if (this.manuallyClosed || this.authInFlight || this.socket?.readyState !== WebSocket.OPEN)
2945
+ return;
2946
+ if (!this.auth.project && !this.auth.tenant && !this.auth.token && !this.auth.fetchToken)
2947
+ return;
2948
+ this.sendAuth(false);
2949
+ }
2584
2950
  scheduleOutboxDrain(delay) {
2585
2951
  if (this.manuallyClosed)
2586
2952
  return;
@@ -2620,7 +2986,6 @@ export class GonvexClient {
2620
2986
  }
2621
2987
  this.directOutboxReducerIds.add(reducerId);
2622
2988
  let entryId;
2623
- let entryAttempts = 0;
2624
2989
  try {
2625
2990
  const scope = this.outboxScope;
2626
2991
  const entry = await this.reducerOutbox.enqueue({
@@ -2633,7 +2998,6 @@ export class GonvexClient {
2633
2998
  state: "inflight",
2634
2999
  });
2635
3000
  entryId = entry.id;
2636
- entryAttempts = entry.attempts;
2637
3001
  if (this.manuallyClosed) {
2638
3002
  await this.reducerOutbox.ack(entry.id);
2639
3003
  throw new GonvexClientError(`Gonvex client was closed before reducer ${ref.path} could be sent.`, { code: "closed", path: ref.path, operation: "reducer" });
@@ -2661,14 +3025,13 @@ export class GonvexClient {
2661
3025
  return result;
2662
3026
  }
2663
3027
  catch (error) {
2664
- if (isQueueableReducerError(error) && options.offline === "queue") {
3028
+ const disposition = deliveryErrorClass(error);
3029
+ if (disposition !== "rejected" && options.offline === "queue") {
2665
3030
  const queuedEntryId = this.optimisticOutboxEntryIds.get(reducerId) ?? entryId;
2666
3031
  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))));
3032
+ // Records backoff (or parks the entry) and arms the client's timer,
3033
+ // exactly like the background drain path.
3034
+ await this.recordDeliveryFailure({ id: queuedEntryId }, error, disposition);
2672
3035
  }
2673
3036
  return { status: "queued", reducerId };
2674
3037
  }
@@ -2917,7 +3280,11 @@ export class GonvexClient {
2917
3280
  if (kind === "reducer" && message.type === "reducer.error") {
2918
3281
  settle();
2919
3282
  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 }));
3283
+ const errorClass = reducerErrorClassFromMessage(message);
3284
+ reject(new GonvexClientError(message.error, {
3285
+ code: "server", path: ref.path, operation: kind,
3286
+ ...(errorClass ? { errorClass, retryable: message.retryable ?? (errorClass === "transient" || errorClass === "unauthenticated") } : {}),
3287
+ }));
2921
3288
  }
2922
3289
  if (kind === "action" && message.type === "action.result") {
2923
3290
  const complete = () => {
@@ -3574,9 +3941,96 @@ function countPendingCalls(calls, kind) {
3574
3941
  }
3575
3942
  return count;
3576
3943
  }
3577
- function isQueueableReducerError(error) {
3578
- return error instanceof GonvexClientError
3579
- && (error.code === "disconnected" || error.code === "timeout");
3944
+ /**
3945
+ * How the outbox treats one failed delivery. Runtimes before
3946
+ * 0.5.2-staging.15 send no class: their server errors stay rejections, as
3947
+ * before, except the two cases the legacy runtime could only express in prose.
3948
+ */
3949
+ function deliveryErrorClass(error) {
3950
+ if (!(error instanceof GonvexClientError))
3951
+ return "transient";
3952
+ switch (error.code) {
3953
+ case "server": return error.errorClass ?? "rejected";
3954
+ case "timeout": return "transient";
3955
+ default: return "network";
3956
+ }
3957
+ }
3958
+ const legacyUnauthenticatedReducerError = "authenticate with an active tenant before calling a Reducer";
3959
+ function reducerErrorClassFromMessage(message) {
3960
+ if (message.class === "rejected" || message.class === "transient" || message.class === "update_required" || message.class === "unauthenticated") {
3961
+ return message.class;
3962
+ }
3963
+ // Legacy runtimes: the only structured signals were these literal messages.
3964
+ if (/STALE_REDUCER_ARTIFACT|CLIENT_UPDATE_REQUIRED/.test(message.error))
3965
+ return "update_required";
3966
+ if (message.error === legacyUnauthenticatedReducerError)
3967
+ return "unauthenticated";
3968
+ return undefined;
3969
+ }
3970
+ function positiveOr(value, fallback) {
3971
+ return typeof value === "number" && value > 0 ? value : fallback;
3972
+ }
3973
+ function summarizeArgs(args) {
3974
+ let text;
3975
+ try {
3976
+ text = JSON.stringify(args) ?? "";
3977
+ }
3978
+ catch {
3979
+ text = String(args);
3980
+ }
3981
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
3982
+ }
3983
+ function outboxIntentFromEntry(entry) {
3984
+ const entities = new Map();
3985
+ for (const patch of entry.patches ?? []) {
3986
+ const entity = patch.entity ?? patch.collection;
3987
+ if (!entity)
3988
+ continue;
3989
+ entities.set(`${entity}\u0000${patch.rowId}`, { entity, id: patch.rowId });
3990
+ }
3991
+ return {
3992
+ id: entry.idempotencyKey,
3993
+ entryId: entry.id,
3994
+ reducer: entry.path,
3995
+ state: entry.state,
3996
+ attempts: entry.attempts,
3997
+ ...(entry.lastError !== undefined ? { lastError: entry.lastError } : {}),
3998
+ ...(entry.lastErrorClass ? { errorClass: entry.lastErrorClass } : {}),
3999
+ createdAt: entry.createdAt,
4000
+ nextAttemptAt: entry.nextAttemptAt,
4001
+ ...(entry.settledAt !== undefined ? { settledAt: entry.settledAt } : {}),
4002
+ args: entry.args,
4003
+ argsSummary: summarizeArgs(entry.args),
4004
+ entities: [...entities.values()],
4005
+ };
4006
+ }
4007
+ function sameIntents(left, right) {
4008
+ if (left.length !== right.length)
4009
+ return false;
4010
+ for (let index = 0; index < left.length; index += 1) {
4011
+ const a = left[index], b = right[index];
4012
+ if (a.id !== b.id || a.entryId !== b.entryId || a.state !== b.state || a.attempts !== b.attempts
4013
+ || a.lastError !== b.lastError || a.errorClass !== b.errorClass || a.nextAttemptAt !== b.nextAttemptAt
4014
+ || a.settledAt !== b.settledAt || a.entities.length !== b.entities.length
4015
+ || a.entities.some((entity, position) => entity.entity !== b.entities[position].entity || entity.id !== b.entities[position].id))
4016
+ return false;
4017
+ }
4018
+ return true;
4019
+ }
4020
+ /** Row status from a list of intents: failed > rejected > syncing. */
4021
+ export function entityStatusFromIntents(intents, entity, id) {
4022
+ let status;
4023
+ for (const intent of intents) {
4024
+ if (!intent.entities.some((candidate) => candidate.entity === entity && candidate.id === id))
4025
+ continue;
4026
+ if (intent.state === "failed")
4027
+ return "failed";
4028
+ if (intent.state === "rejected")
4029
+ status = "rejected";
4030
+ else if (intent.state !== "committed" && status === undefined)
4031
+ status = "syncing";
4032
+ }
4033
+ return status;
3580
4034
  }
3581
4035
  function reducerErrorMessage(error) {
3582
4036
  return error instanceof Error ? error.message : String(error);