@spooky-sync/core 0.0.1-canary.178 → 0.0.1-canary.179

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
@@ -4974,10 +4974,20 @@ function rowToUpEvent(r, logger) {
4974
4974
 
4975
4975
  //#endregion
4976
4976
  //#region src/modules/sync/queue/queue-down.ts
4977
+ /**
4978
+ * How many times a failing event keeps its place at the head before it is
4979
+ * rotated to the back. A transient failure (the SSP still bootstrapping) clears
4980
+ * well inside this, so ordering is preserved for the common case; a permanently
4981
+ * rejected event (a permission the SSP refuses to lower) stops holding every
4982
+ * other query hostage behind it.
4983
+ */
4984
+ const MAX_HEAD_RETRIES = 3;
4977
4985
  var DownQueue = class {
4978
4986
  queue = [];
4979
4987
  _events;
4980
4988
  logger;
4989
+ /** Consecutive failures per queued event; cleared when it finally succeeds. */
4990
+ failures = /* @__PURE__ */ new WeakMap();
4981
4991
  get events() {
4982
4992
  return this._events;
4983
4993
  }
@@ -5008,13 +5018,20 @@ var DownQueue = class {
5008
5018
  const event = this.queue.shift();
5009
5019
  if (event) try {
5010
5020
  await fn(event);
5021
+ this.failures.delete(event);
5011
5022
  } catch (error) {
5023
+ const attempts = (this.failures.get(event) ?? 0) + 1;
5024
+ this.failures.set(event, attempts);
5025
+ const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
5026
+ if (starvingOthers) this.queue.push(event);
5027
+ else this.queue.unshift(event);
5012
5028
  this.logger.error({
5013
5029
  error,
5014
5030
  event,
5031
+ attempts,
5032
+ rotated: starvingOthers,
5015
5033
  Category: "sp00ky-client::DownQueue::next"
5016
5034
  }, "Failed to process query");
5017
- this.queue.unshift(event);
5018
5035
  throw error;
5019
5036
  }
5020
5037
  }
@@ -5378,11 +5395,18 @@ var SyncEngine = class {
5378
5395
  * SyncScheduler manages when to sync: queue management and orchestration.
5379
5396
  * Decides the order and timing of sync operations.
5380
5397
  */
5398
+ /** Backoff for re-draining a queue that halted on an error. */
5399
+ const RETRY_BASE_MS = 500;
5400
+ const RETRY_MAX_MS = 15e3;
5381
5401
  var SyncScheduler = class {
5382
5402
  isSyncingUp = false;
5383
5403
  isSyncingDown = false;
5384
5404
  paused = false;
5385
5405
  pauseWaiters = [];
5406
+ upRetryTimer;
5407
+ downRetryTimer;
5408
+ upRetryAttempt = 0;
5409
+ downRetryAttempt = 0;
5386
5410
  constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
5387
5411
  this.upQueue = upQueue;
5388
5412
  this.downQueue = downQueue;
@@ -5418,12 +5442,16 @@ var SyncScheduler = class {
5418
5442
  */
5419
5443
  pause() {
5420
5444
  this.paused = true;
5445
+ this.clearRetryTimers();
5421
5446
  if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
5422
5447
  return new Promise((resolve) => this.pauseWaiters.push(resolve));
5423
5448
  }
5424
5449
  resume() {
5425
5450
  this.paused = false;
5451
+ this.upRetryAttempt = 0;
5452
+ this.downRetryAttempt = 0;
5426
5453
  this.syncUp();
5454
+ this.syncDown();
5427
5455
  }
5428
5456
  maybeResolvePause() {
5429
5457
  if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
@@ -5431,6 +5459,46 @@ var SyncScheduler = class {
5431
5459
  this.pauseWaiters = [];
5432
5460
  for (const resolve of waiters) resolve();
5433
5461
  }
5462
+ /** Exponential backoff, capped. Attempt 0 is the first retry. */
5463
+ retryDelay(attempt) {
5464
+ return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
5465
+ }
5466
+ scheduleUpRetry() {
5467
+ if (this.paused || this.upRetryTimer || this.upQueue.size === 0) return;
5468
+ const delay = this.retryDelay(this.upRetryAttempt++);
5469
+ this.upRetryTimer = setTimeout(() => {
5470
+ this.upRetryTimer = void 0;
5471
+ this.syncUp();
5472
+ }, delay);
5473
+ }
5474
+ /**
5475
+ * Re-arm the down pass. With no argument this is failure backoff and the
5476
+ * streak grows; with an explicit delay it is a yield (the up-queue holds the
5477
+ * floor), which is not a failure and must not push the backoff out.
5478
+ */
5479
+ scheduleDownRetry(delayMs) {
5480
+ if (this.paused || this.downRetryTimer || this.downQueue.size === 0) return;
5481
+ const delay = delayMs ?? this.retryDelay(this.downRetryAttempt++);
5482
+ this.downRetryTimer = setTimeout(() => {
5483
+ this.downRetryTimer = void 0;
5484
+ this.syncDown();
5485
+ }, delay);
5486
+ }
5487
+ clearRetryTimers() {
5488
+ if (this.upRetryTimer) {
5489
+ clearTimeout(this.upRetryTimer);
5490
+ this.upRetryTimer = void 0;
5491
+ }
5492
+ if (this.downRetryTimer) {
5493
+ clearTimeout(this.downRetryTimer);
5494
+ this.downRetryTimer = void 0;
5495
+ }
5496
+ }
5497
+ /** Stop all pending retries. Call when tearing the client down. */
5498
+ dispose() {
5499
+ this.paused = true;
5500
+ this.clearRetryTimers();
5501
+ }
5434
5502
  /**
5435
5503
  * Process upload queue
5436
5504
  */
@@ -5444,8 +5512,10 @@ var SyncScheduler = class {
5444
5512
  processedAny = true;
5445
5513
  }
5446
5514
  if (processedAny) this.onSyncOutcome?.(true);
5515
+ this.upRetryAttempt = 0;
5447
5516
  } catch (error) {
5448
5517
  this.onSyncOutcome?.(false, error);
5518
+ this.scheduleUpRetry();
5449
5519
  this.logger.debug({
5450
5520
  error,
5451
5521
  Category: "sp00ky-client::SyncScheduler::syncUp"
@@ -5461,7 +5531,10 @@ var SyncScheduler = class {
5461
5531
  */
5462
5532
  async syncDown() {
5463
5533
  if (this.isSyncingDown || this.paused) return;
5464
- if (this.upQueue.size > 0) return;
5534
+ if (this.upQueue.size > 0) {
5535
+ this.scheduleDownRetry(RETRY_BASE_MS);
5536
+ return;
5537
+ }
5465
5538
  this.isSyncingDown = true;
5466
5539
  let processedAny = false;
5467
5540
  try {
@@ -5471,8 +5544,10 @@ var SyncScheduler = class {
5471
5544
  processedAny = true;
5472
5545
  }
5473
5546
  if (processedAny) this.onSyncOutcome?.(true);
5547
+ this.downRetryAttempt = 0;
5474
5548
  } catch (error) {
5475
5549
  this.onSyncOutcome?.(false, error);
5550
+ this.scheduleDownRetry();
5476
5551
  this.logger.debug({
5477
5552
  error,
5478
5553
  Category: "sp00ky-client::SyncScheduler::syncDown"
@@ -6928,8 +7003,8 @@ function selfAllowlistedVariant(flag, userId) {
6928
7003
 
6929
7004
  //#endregion
6930
7005
  //#region src/modules/devtools/index.ts
6931
- const CORE_VERSION = "0.0.1-canary.178";
6932
- const WASM_VERSION = "0.0.1-canary.178";
7006
+ const CORE_VERSION = "0.0.1-canary.179";
7007
+ const WASM_VERSION = "0.0.1-canary.179";
6933
7008
  const SURREAL_VERSION = "3.0.3";
6934
7009
  var DevToolsService = class DevToolsService {
6935
7010
  eventsHistory = [];
@@ -11360,7 +11435,7 @@ var Sp00kyClient = class {
11360
11435
  return new TabsCoordinator({
11361
11436
  tabId,
11362
11437
  fingerprint: computeTabsFingerprint({
11363
- coreVersion: "0.0.1-canary.178",
11438
+ coreVersion: "0.0.1-canary.179",
11364
11439
  schemaHash: hash53(this.config.schemaSurql),
11365
11440
  endpoint: this.config.database.endpoint ?? "",
11366
11441
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.178",
3
+ "version": "0.0.1-canary.179",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.178",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.178",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.179",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.179",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DownQueue } from './queue-down';
3
+ import type { DownEvent } from './queue-down';
4
+ import type { LocalStore } from '../../../services/database/index';
5
+
6
+ const silentLogger = {
7
+ child: () => silentLogger,
8
+ debug: () => {},
9
+ info: () => {},
10
+ warn: () => {},
11
+ error: () => {},
12
+ } as any;
13
+
14
+ const register = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
15
+
16
+ const hashOf = (e: DownEvent) => e.payload.hash;
17
+
18
+ function makeQueue() {
19
+ return new DownQueue({} as LocalStore, silentLogger);
20
+ }
21
+
22
+ describe('DownQueue.next failure handling', () => {
23
+ it('re-heads a failing event so a transient failure keeps its order', async () => {
24
+ const q = makeQueue();
25
+ q.push(register('a'));
26
+ q.push(register('b'));
27
+
28
+ const seen: string[] = [];
29
+ await expect(
30
+ q.next(async (e) => {
31
+ seen.push(hashOf(e));
32
+ throw new Error('503');
33
+ })
34
+ ).rejects.toThrow('503');
35
+
36
+ expect(seen).toEqual(['a']);
37
+ // 'a' is back at the head, ahead of 'b'.
38
+ await expect(q.next(async (e) => { seen.push(hashOf(e)); throw new Error('503'); })).rejects.toThrow();
39
+ expect(seen).toEqual(['a', 'a']);
40
+ });
41
+
42
+ it('rotates a persistently failing event to the back so others can proceed', async () => {
43
+ const q = makeQueue();
44
+ q.push(register('poison'));
45
+ q.push(register('healthy'));
46
+
47
+ const seen: string[] = [];
48
+ const failPoison = async (e: DownEvent) => {
49
+ seen.push(hashOf(e));
50
+ if (hashOf(e) === 'poison') throw new Error('400 rejected');
51
+ };
52
+
53
+ // Three attempts keep the head; the third rotates it behind 'healthy'.
54
+ for (let i = 0; i < 3; i++) {
55
+ await expect(q.next(failPoison)).rejects.toThrow();
56
+ }
57
+ expect(seen).toEqual(['poison', 'poison', 'poison']);
58
+
59
+ // 'healthy' is no longer starved.
60
+ await q.next(failPoison);
61
+ expect(seen).toEqual(['poison', 'poison', 'poison', 'healthy']);
62
+ expect(q.size).toBe(1); // only the poison event remains
63
+ });
64
+
65
+ it('does not rotate when nothing else is waiting', async () => {
66
+ const q = makeQueue();
67
+ q.push(register('only'));
68
+
69
+ for (let i = 0; i < 5; i++) {
70
+ await expect(
71
+ q.next(async () => {
72
+ throw new Error('boom');
73
+ })
74
+ ).rejects.toThrow();
75
+ }
76
+ expect(q.size).toBe(1);
77
+ });
78
+
79
+ it('clears the failure count once an event succeeds', async () => {
80
+ const q = makeQueue();
81
+ const event = register('a');
82
+ q.push(event);
83
+ q.push(register('b'));
84
+
85
+ await expect(
86
+ q.next(async () => {
87
+ throw new Error('boom');
88
+ })
89
+ ).rejects.toThrow();
90
+ // Succeeds on the retry, so its streak resets rather than carrying over.
91
+ await q.next(async () => {});
92
+ expect(q.size).toBe(1);
93
+
94
+ q.push(event);
95
+ // A fresh streak: it keeps the head again rather than rotating immediately.
96
+ await expect(
97
+ q.next(async () => {
98
+ throw new Error('boom');
99
+ })
100
+ ).rejects.toThrow();
101
+ const drained: string[] = [];
102
+ await q.next(async (e) => {
103
+ drained.push(hashOf(e));
104
+ });
105
+ expect(drained).toEqual(['b']);
106
+ });
107
+ });
@@ -37,10 +37,21 @@ export type CleanupEvent = {
37
37
 
38
38
  export type DownEvent = RegisterEvent | SyncEvent | HeartbeatEvent | CleanupEvent;
39
39
 
40
+ /**
41
+ * How many times a failing event keeps its place at the head before it is
42
+ * rotated to the back. A transient failure (the SSP still bootstrapping) clears
43
+ * well inside this, so ordering is preserved for the common case; a permanently
44
+ * rejected event (a permission the SSP refuses to lower) stops holding every
45
+ * other query hostage behind it.
46
+ */
47
+ const MAX_HEAD_RETRIES = 3;
48
+
40
49
  export class DownQueue {
41
50
  private queue: DownEvent[] = [];
42
51
  private _events: SyncQueueEventSystem;
43
52
  private logger: Logger;
53
+ /** Consecutive failures per queued event; cleared when it finally succeeds. */
54
+ private failures = new WeakMap<DownEvent, number>();
44
55
 
45
56
  get events(): SyncQueueEventSystem {
46
57
  return this._events;
@@ -83,12 +94,23 @@ export class DownQueue {
83
94
  if (event) {
84
95
  try {
85
96
  await fn(event);
97
+ this.failures.delete(event);
86
98
  } catch (error) {
99
+ const attempts = (this.failures.get(event) ?? 0) + 1;
100
+ this.failures.set(event, attempts);
101
+ // Re-head so a transient failure keeps its ordering, but give up the
102
+ // head once it looks permanent and there is other work waiting — one
103
+ // unregisterable query must not stall every other query's registration.
104
+ const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
105
+ if (starvingOthers) {
106
+ this.queue.push(event);
107
+ } else {
108
+ this.queue.unshift(event);
109
+ }
87
110
  this.logger.error(
88
- { error, event, Category: 'sp00ky-client::DownQueue::next' },
111
+ { error, event, attempts, rotated: starvingOthers, Category: 'sp00ky-client::DownQueue::next' },
89
112
  'Failed to process query'
90
113
  );
91
- this.queue.unshift(event);
92
114
  throw error;
93
115
  }
94
116
  }
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { SyncScheduler } from './scheduler';
3
+ import type { UpQueue, DownQueue, DownEvent, UpEvent } from './queue/index';
4
+
5
+ // A queue whose drain throws re-queues the failing item at the HEAD and stops
6
+ // the pass (see DownQueue.next). Nothing used to re-arm it: the queues only
7
+ // moved on a fresh enqueue, so one transient failure — canonically the SSP
8
+ // answering 503 NOT_READY for the whole of its bootstrap window — parked every
9
+ // pending `register` forever, and the `useQuery` waiting on it never left its
10
+ // loading state. These cover the backoff that makes that self-heal.
11
+
12
+ const silentLogger = {
13
+ child: () => silentLogger,
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ } as any;
19
+
20
+ /** A queue that mirrors the real ones: a throwing handler re-heads the item. */
21
+ function makeQueue<E>(items: E[]) {
22
+ return {
23
+ queue: [...items],
24
+ get size() {
25
+ return this.queue.length;
26
+ },
27
+ events: { subscribe: () => {} },
28
+ loadFromDatabase: async () => {},
29
+ clear() {
30
+ this.queue = [];
31
+ },
32
+ async next(fn: (event: E) => Promise<void>) {
33
+ const event = this.queue.shift();
34
+ if (!event) return;
35
+ try {
36
+ await fn(event);
37
+ } catch (err) {
38
+ this.queue.unshift(event);
39
+ throw err;
40
+ }
41
+ },
42
+ };
43
+ }
44
+
45
+ const downEvent = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
46
+ const upEvent = (n: number) => ({ type: 'delete', mutation_id: n, record_id: n }) as unknown as UpEvent;
47
+
48
+ describe('SyncScheduler retry', () => {
49
+ beforeEach(() => vi.useFakeTimers());
50
+ afterEach(() => vi.useRealTimers());
51
+
52
+ it('re-drains a failed down event instead of parking it forever', async () => {
53
+ const downQueue = makeQueue([downEvent('q1')]);
54
+ const upQueue = makeQueue<UpEvent>([]);
55
+ let attempts = 0;
56
+ const scheduler = new SyncScheduler(
57
+ upQueue as unknown as UpQueue,
58
+ downQueue as unknown as DownQueue,
59
+ async () => {},
60
+ async () => {
61
+ attempts++;
62
+ // Fail the way a bootstrapping SSP does, then succeed.
63
+ if (attempts < 3) throw new Error('503 NOT_READY');
64
+ },
65
+ silentLogger
66
+ );
67
+
68
+ await scheduler.syncDown();
69
+ expect(attempts).toBe(1);
70
+ expect(downQueue.size).toBe(1); // re-headed, not dropped
71
+
72
+ // Backoff: 500ms, then 1000ms.
73
+ await vi.advanceTimersByTimeAsync(500);
74
+ expect(attempts).toBe(2);
75
+ await vi.advanceTimersByTimeAsync(1000);
76
+ expect(attempts).toBe(3);
77
+
78
+ expect(downQueue.size).toBe(0);
79
+ });
80
+
81
+ it('stops retrying once the queue drains', async () => {
82
+ const downQueue = makeQueue([downEvent('q1')]);
83
+ const upQueue = makeQueue<UpEvent>([]);
84
+ let attempts = 0;
85
+ const scheduler = new SyncScheduler(
86
+ upQueue as unknown as UpQueue,
87
+ downQueue as unknown as DownQueue,
88
+ async () => {},
89
+ async () => {
90
+ attempts++;
91
+ throw new Error('boom');
92
+ },
93
+ silentLogger
94
+ );
95
+
96
+ await scheduler.syncDown();
97
+ await vi.advanceTimersByTimeAsync(500);
98
+ expect(attempts).toBe(2);
99
+
100
+ // Drain it out from under the scheduler; the next retry finds nothing and
101
+ // schedules no further work.
102
+ downQueue.clear();
103
+ await vi.advanceTimersByTimeAsync(1000);
104
+ expect(attempts).toBe(2);
105
+ await vi.advanceTimersByTimeAsync(60_000);
106
+ expect(attempts).toBe(2);
107
+ });
108
+
109
+ it('comes back for the down queue while the up queue holds the floor', async () => {
110
+ const downQueue = makeQueue([downEvent('q1')]);
111
+ const upQueue = makeQueue<UpEvent>([upEvent(1)]);
112
+ let downAttempts = 0;
113
+ const scheduler = new SyncScheduler(
114
+ upQueue as unknown as UpQueue,
115
+ downQueue as unknown as DownQueue,
116
+ async () => {},
117
+ async () => {
118
+ downAttempts++;
119
+ },
120
+ silentLogger
121
+ );
122
+
123
+ // Yields to the non-empty up queue.
124
+ await scheduler.syncDown();
125
+ expect(downAttempts).toBe(0);
126
+
127
+ // Once the up queue empties, the re-armed pass picks the down event up
128
+ // without needing a fresh enqueue.
129
+ upQueue.clear();
130
+ await vi.advanceTimersByTimeAsync(500);
131
+ expect(downAttempts).toBe(1);
132
+ });
133
+
134
+ it('pause cancels pending retries', async () => {
135
+ const downQueue = makeQueue([downEvent('q1')]);
136
+ const upQueue = makeQueue<UpEvent>([]);
137
+ let attempts = 0;
138
+ const scheduler = new SyncScheduler(
139
+ upQueue as unknown as UpQueue,
140
+ downQueue as unknown as DownQueue,
141
+ async () => {},
142
+ async () => {
143
+ attempts++;
144
+ throw new Error('boom');
145
+ },
146
+ silentLogger
147
+ );
148
+
149
+ await scheduler.syncDown();
150
+ expect(attempts).toBe(1);
151
+
152
+ await scheduler.pause();
153
+ await vi.advanceTimersByTimeAsync(60_000);
154
+ expect(attempts).toBe(1);
155
+ });
156
+ });
@@ -6,11 +6,25 @@ import { SyncQueueEventTypes } from './events/index';
6
6
  * SyncScheduler manages when to sync: queue management and orchestration.
7
7
  * Decides the order and timing of sync operations.
8
8
  */
9
+ /** Backoff for re-draining a queue that halted on an error. */
10
+ const RETRY_BASE_MS = 500;
11
+ const RETRY_MAX_MS = 15_000;
12
+
9
13
  export class SyncScheduler {
10
14
  private isSyncingUp: boolean = false;
11
15
  private isSyncingDown: boolean = false;
12
16
  private paused: boolean = false;
13
17
  private pauseWaiters: Array<() => void> = [];
18
+ // A failed drain re-queues its item at the HEAD (see DownQueue.next) and
19
+ // stops the pass. Without a timer nothing ever drains it again: the queues
20
+ // only move on a fresh enqueue, so a transient failure — canonically the
21
+ // SSP answering 503 NOT_READY for the whole of its bootstrap window — left
22
+ // every pending `register` parked forever and its `useQuery` loading forever.
23
+ // Retry on a backoff so that heals itself instead of needing a reload.
24
+ private upRetryTimer?: ReturnType<typeof setTimeout>;
25
+ private downRetryTimer?: ReturnType<typeof setTimeout>;
26
+ private upRetryAttempt = 0;
27
+ private downRetryAttempt = 0;
14
28
 
15
29
  constructor(
16
30
  private upQueue: UpQueue,
@@ -62,13 +76,18 @@ export class SyncScheduler {
62
76
  */
63
77
  pause(): Promise<void> {
64
78
  this.paused = true;
79
+ this.clearRetryTimers();
65
80
  if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
66
81
  return new Promise<void>((resolve) => this.pauseWaiters.push(resolve));
67
82
  }
68
83
 
69
84
  resume(): void {
70
85
  this.paused = false;
86
+ // A resume is a fresh start, not a continuation of the failing streak.
87
+ this.upRetryAttempt = 0;
88
+ this.downRetryAttempt = 0;
71
89
  void this.syncUp();
90
+ void this.syncDown();
72
91
  }
73
92
 
74
93
  private maybeResolvePause() {
@@ -78,6 +97,51 @@ export class SyncScheduler {
78
97
  for (const resolve of waiters) resolve();
79
98
  }
80
99
 
100
+ /** Exponential backoff, capped. Attempt 0 is the first retry. */
101
+ private retryDelay(attempt: number): number {
102
+ return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
103
+ }
104
+
105
+ private scheduleUpRetry() {
106
+ if (this.paused || this.upRetryTimer || this.upQueue.size === 0) return;
107
+ const delay = this.retryDelay(this.upRetryAttempt++);
108
+ this.upRetryTimer = setTimeout(() => {
109
+ this.upRetryTimer = undefined;
110
+ void this.syncUp();
111
+ }, delay);
112
+ }
113
+
114
+ /**
115
+ * Re-arm the down pass. With no argument this is failure backoff and the
116
+ * streak grows; with an explicit delay it is a yield (the up-queue holds the
117
+ * floor), which is not a failure and must not push the backoff out.
118
+ */
119
+ private scheduleDownRetry(delayMs?: number) {
120
+ if (this.paused || this.downRetryTimer || this.downQueue.size === 0) return;
121
+ const delay = delayMs ?? this.retryDelay(this.downRetryAttempt++);
122
+ this.downRetryTimer = setTimeout(() => {
123
+ this.downRetryTimer = undefined;
124
+ void this.syncDown();
125
+ }, delay);
126
+ }
127
+
128
+ private clearRetryTimers() {
129
+ if (this.upRetryTimer) {
130
+ clearTimeout(this.upRetryTimer);
131
+ this.upRetryTimer = undefined;
132
+ }
133
+ if (this.downRetryTimer) {
134
+ clearTimeout(this.downRetryTimer);
135
+ this.downRetryTimer = undefined;
136
+ }
137
+ }
138
+
139
+ /** Stop all pending retries. Call when tearing the client down. */
140
+ dispose(): void {
141
+ this.paused = true;
142
+ this.clearRetryTimers();
143
+ }
144
+
81
145
  /**
82
146
  * Process upload queue
83
147
  */
@@ -91,8 +155,10 @@ export class SyncScheduler {
91
155
  processedAny = true;
92
156
  }
93
157
  if (processedAny) this.onSyncOutcome?.(true);
158
+ this.upRetryAttempt = 0;
94
159
  } catch (error) {
95
160
  this.onSyncOutcome?.(false, error);
161
+ this.scheduleUpRetry();
96
162
  // syncUp runs fire-and-forget — it's wired to the MutationEnqueued event
97
163
  // (broadcast synchronously, return value dropped) and is also kicked off
98
164
  // via `void this.syncDown()` below. A rejection escaping here therefore
@@ -116,7 +182,15 @@ export class SyncScheduler {
116
182
  */
117
183
  async syncDown() {
118
184
  if (this.isSyncingDown || this.paused) return;
119
- if (this.upQueue.size > 0) return;
185
+ // Down-sync yields to a non-empty up-queue so a register never races ahead
186
+ // of the mutation it should observe. That yield used to be permanent: if
187
+ // the up-queue never drained, nothing re-armed the down pass. Come back on
188
+ // the backoff instead, so a wedged push delays reads rather than killing
189
+ // them.
190
+ if (this.upQueue.size > 0) {
191
+ this.scheduleDownRetry(RETRY_BASE_MS);
192
+ return;
193
+ }
120
194
 
121
195
  this.isSyncingDown = true;
122
196
  let processedAny = false;
@@ -127,8 +201,10 @@ export class SyncScheduler {
127
201
  processedAny = true;
128
202
  }
129
203
  if (processedAny) this.onSyncOutcome?.(true);
204
+ this.downRetryAttempt = 0;
130
205
  } catch (error) {
131
206
  this.onSyncOutcome?.(false, error);
207
+ this.scheduleDownRetry();
132
208
  // Same fire-and-forget story as syncUp: this is the QueryItemEnqueued
133
209
  // subscriber (and is also called via `void this.syncDown()`), so a thrown
134
210
  // error here becomes an unhandled rejection. The canonical case is a