@syncular/client 0.26.6 → 0.28.0

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/client.js CHANGED
@@ -176,6 +176,7 @@ export class SyncClient {
176
176
  #localResetEpoch = 0;
177
177
  /** Retry policy belongs to the operation that classified the failure. */
178
178
  #retryDelayMs = 250;
179
+ #roundRetryDelayMs;
179
180
  #hasBlobs;
180
181
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
181
182
  #presence = new Map();
@@ -2334,6 +2335,9 @@ export class SyncClient {
2334
2335
  errorCode: typeof code === 'string'
2335
2336
  ? this.#diagnosticCode(code)
2336
2337
  : 'client.unknown_failure',
2338
+ ...(this.#roundRetryDelayMs !== undefined
2339
+ ? { retryDelayMs: this.#roundRetryDelayMs }
2340
+ : {}),
2337
2341
  });
2338
2342
  this.#lastRound = {
2339
2343
  status: 'failed',
@@ -2381,6 +2385,7 @@ export class SyncClient {
2381
2385
  : 'client.unknown_failure';
2382
2386
  }
2383
2387
  async #runSync() {
2388
+ this.#roundRetryDelayMs = undefined;
2384
2389
  this.#progress.emit({
2385
2390
  attempt: ++this.#progressAttempt,
2386
2391
  state: 'running',
@@ -2508,6 +2513,7 @@ export class SyncClient {
2508
2513
  delayMs: delay,
2509
2514
  };
2510
2515
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
2516
+ this.#roundRetryDelayMs = delay;
2511
2517
  this.#emitSyncIntent(intent);
2512
2518
  }
2513
2519
  throw error;
package/dist/errors.d.ts CHANGED
@@ -22,6 +22,21 @@ export declare class ClientSyncError extends Error {
22
22
  readonly retryable: boolean;
23
23
  constructor(code: string, message: string, retryable?: boolean);
24
24
  }
25
+ /**
26
+ * SPEC §7.5: the latest sync attempt failed while a live query still waited
27
+ * for its required coverage. `code` is the attempt's static progress error
28
+ * code; `attempt` is the progress attempt that failed. `retryable` is true when
29
+ * the client scheduled a background retry for the attempt, and `retryDelayMs`
30
+ * is that retry's delay from the failure; a non-retryable failure has no
31
+ * delay and no automatic next attempt. The query returns to waiting when the
32
+ * next attempt starts.
33
+ */
34
+ export declare class SyncRoundFailedError extends ClientSyncError {
35
+ readonly name = "SyncRoundFailedError";
36
+ readonly attempt: number;
37
+ readonly retryDelayMs: number | undefined;
38
+ constructor(code: string, attempt: number, retryDelayMs: number | undefined);
39
+ }
25
40
  /**
26
41
  * SPEC §8.8: the `required` realtime policy refused a round because the
27
42
  * socket is not connected. `state` is the availability state that refused
package/dist/errors.js CHANGED
@@ -25,6 +25,25 @@ export class ClientSyncError extends Error {
25
25
  this.retryable = retryable;
26
26
  }
27
27
  }
28
+ /**
29
+ * SPEC §7.5: the latest sync attempt failed while a live query still waited
30
+ * for its required coverage. `code` is the attempt's static progress error
31
+ * code; `attempt` is the progress attempt that failed. `retryable` is true when
32
+ * the client scheduled a background retry for the attempt, and `retryDelayMs`
33
+ * is that retry's delay from the failure; a non-retryable failure has no
34
+ * delay and no automatic next attempt. The query returns to waiting when the
35
+ * next attempt starts.
36
+ */
37
+ export class SyncRoundFailedError extends ClientSyncError {
38
+ name = 'SyncRoundFailedError';
39
+ attempt;
40
+ retryDelayMs;
41
+ constructor(code, attempt, retryDelayMs) {
42
+ super(code, 'the sync round failed before the query coverage completed', retryDelayMs !== undefined);
43
+ this.attempt = attempt;
44
+ this.retryDelayMs = retryDelayMs;
45
+ }
46
+ }
28
47
  /**
29
48
  * SPEC §8.8: the `required` realtime policy refused a round because the
30
49
  * socket is not connected. `state` is the availability state that refused
@@ -11,6 +11,8 @@ export interface SyncProgress {
11
11
  readonly rowsProcessed: number;
12
12
  readonly rowsTotal?: number;
13
13
  readonly errorCode?: string;
14
+ /** Present on `failed` when the core scheduled a background retry. */
15
+ readonly retryDelayMs?: number;
14
16
  }
15
17
  export type SyncProgressListener = (progress: SyncProgress) => void;
16
18
  /** Shared by the direct client and event-forwarding handles. */
@@ -1,8 +1,10 @@
1
1
  import { type SyncAvailability } from './availability.js';
2
2
  import type { CommitOutcome, ClientSnapshotReader, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
3
3
  import type { SqlValue } from './database.js';
4
+ import { SyncRoundFailedError } from './errors.js';
4
5
  import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
5
6
  import type { LeadershipState } from './multi-tab.js';
7
+ import type { SyncProgressListener } from './progress.js';
6
8
  import { type WindowBase } from './window.js';
7
9
  export interface QueryDependency {
8
10
  readonly table: string;
@@ -33,6 +35,7 @@ export interface ReactiveQueryClient extends Pick<ClientSnapshotReader, 'statusS
33
35
  querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
34
36
  leadershipSnapshot?(): LeadershipState | undefined;
35
37
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
38
+ onProgress(listener: SyncProgressListener): () => void;
36
39
  conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
37
40
  rejections(): readonly unknown[] | Promise<readonly unknown[]>;
38
41
  setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
@@ -74,6 +77,8 @@ export declare class ReactiveClientStore {
74
77
  readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
75
78
  readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
76
79
  readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
80
+ /** The latest sync attempt's failure until the next attempt starts. */
81
+ readonly roundFailure: ExternalStoreEntry<SyncRoundFailedError | undefined>;
77
82
  constructor(client: ReactiveQueryClient);
78
83
  query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
79
84
  availabilitySnapshot(): SyncAvailability;
@@ -1,5 +1,5 @@
1
1
  import { classifySyncAvailability, } from './availability.js';
2
- import { ClientSyncError } from './errors.js';
2
+ import { ClientSyncError, SyncRoundFailedError } from './errors.js';
3
3
  import { windowBaseKey } from './window.js';
4
4
  function errorOf(value) {
5
5
  return value instanceof Error ? value : new Error(String(value));
@@ -280,6 +280,7 @@ class QueryEntry {
280
280
  #claimError;
281
281
  #claimPending = false;
282
282
  #offStatus;
283
+ #offRound;
283
284
  constructor(store, spec, key, cache) {
284
285
  this.store = store;
285
286
  this.spec = spec;
@@ -298,6 +299,7 @@ class QueryEntry {
298
299
  this.#listeners.add(notify);
299
300
  if (this.#listeners.size === 1) {
300
301
  this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
302
+ this.#offRound = this.store.roundFailure.subscribe(() => this.#onRoundFailureChange());
301
303
  this.#onAvailabilityChange();
302
304
  if (this.spec.claimCoverage !== false) {
303
305
  const claims = [];
@@ -349,6 +351,8 @@ class QueryEntry {
349
351
  this.cache.deactivate(this.key, this);
350
352
  this.#offStatus?.();
351
353
  this.#offStatus = undefined;
354
+ this.#offRound?.();
355
+ this.#offRound = undefined;
352
356
  this.store.releaseWindowClaims(this.#owner);
353
357
  }
354
358
  };
@@ -373,6 +377,8 @@ class QueryEntry {
373
377
  this.#listeners.clear();
374
378
  this.#offStatus?.();
375
379
  this.#offStatus = undefined;
380
+ this.#offRound?.();
381
+ this.#offRound = undefined;
376
382
  this.store.releaseWindowClaims(this.#owner);
377
383
  this.reset();
378
384
  }
@@ -448,6 +454,30 @@ class QueryEntry {
448
454
  if (wasBlocked)
449
455
  this.#requestRead();
450
456
  }
457
+ /** SPEC §7.5 failed transfer: incomplete coverage stops waiting on a failed
458
+ * sync attempt and resumes waiting when the next attempt starts. */
459
+ #onRoundFailureChange() {
460
+ const failure = this.store.roundFailure.getSnapshot();
461
+ if (failure !== undefined) {
462
+ if (this.#state.phase === 'loading' || this.#state.phase === 'partial')
463
+ this.#publish({
464
+ ...this.#state,
465
+ phase: 'error',
466
+ error: failure,
467
+ isRefreshing: false,
468
+ });
469
+ return;
470
+ }
471
+ if (this.#state.phase === 'error' &&
472
+ this.#state.error instanceof SyncRoundFailedError) {
473
+ this.#publish({
474
+ ...this.#state,
475
+ phase: this.#state.rows.length > 0 ? 'partial' : 'loading',
476
+ error: undefined,
477
+ });
478
+ this.#requestRead();
479
+ }
480
+ }
451
481
  async #readLoop() {
452
482
  if (this.#running || this.#listeners.size === 0)
453
483
  return;
@@ -502,16 +532,21 @@ class QueryEntry {
502
532
  ? snapshot.rows
503
533
  : snapshot.rows.map(this.spec.mapRow);
504
534
  const rows = reconcileRows(this.#state.rows, mappedRows, this.spec.rowKey);
535
+ const roundFailure = snapshot.coverage.complete
536
+ ? undefined
537
+ : this.store.roundFailure.getSnapshot();
505
538
  const phase = snapshot.coverage.complete
506
539
  ? 'ready'
507
- : rows.length > 0
508
- ? 'partial'
509
- : 'loading';
540
+ : roundFailure !== undefined
541
+ ? 'error'
542
+ : rows.length > 0
543
+ ? 'partial'
544
+ : 'loading';
510
545
  this.#publish({
511
546
  rows,
512
547
  phase,
513
548
  revision: snapshot.revision,
514
- error: undefined,
549
+ error: roundFailure,
515
550
  isRefreshing: false,
516
551
  availability,
517
552
  });
@@ -675,6 +710,9 @@ export class ReactiveClientStore {
675
710
  status;
676
711
  conflicts;
677
712
  outcomes;
713
+ /** The latest sync attempt's failure until the next attempt starts. */
714
+ roundFailure;
715
+ #offProgress;
678
716
  constructor(client) {
679
717
  this.client = client;
680
718
  const status = new ValueEntry({
@@ -737,6 +775,8 @@ export class ReactiveClientStore {
737
775
  this.status = status;
738
776
  this.conflicts = conflicts;
739
777
  this.outcomes = outcomes;
778
+ const roundFailure = new ValueEntry(undefined, async () => roundFailure.getSnapshot());
779
+ this.roundFailure = roundFailure;
740
780
  this.start();
741
781
  }
742
782
  query(spec) {
@@ -932,6 +972,16 @@ export class ReactiveClientStore {
932
972
  if (batch.outcomesChanged)
933
973
  this.outcomes.refresh();
934
974
  });
975
+ const roundFailure = this.roundFailure;
976
+ this.#offProgress = this.client.onProgress((progress) => {
977
+ const current = roundFailure.getSnapshot();
978
+ if (progress.state === 'failed' && current?.attempt !== progress.attempt)
979
+ roundFailure.set(new SyncRoundFailedError(progress.errorCode ?? 'client.unknown_failure', progress.attempt, progress.retryDelayMs));
980
+ else if (progress.state !== 'failed' &&
981
+ current !== undefined &&
982
+ progress.attempt !== current.attempt)
983
+ roundFailure.set(undefined);
984
+ });
935
985
  this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
936
986
  const previous = this.status.getSnapshot();
937
987
  this.status.set({
@@ -955,6 +1005,9 @@ export class ReactiveClientStore {
955
1005
  this.#offChange = undefined;
956
1006
  this.#offLeadership?.();
957
1007
  this.#offLeadership = undefined;
1008
+ this.#offProgress?.();
1009
+ this.#offProgress = undefined;
1010
+ this.roundFailure.set(undefined);
958
1011
  for (const group of this.#windowClaims.values()) {
959
1012
  for (const waiter of group.waiters.splice(0)) {
960
1013
  waiter.reject(new ClientSyncError('client.reactive_store_disposed', 'reactive store disposed'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.26.6",
3
+ "version": "0.28.0",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -89,10 +89,10 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.26.6"
92
+ "@syncular/core": "0.28.0"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.26.6",
95
+ "@syncular/server": "0.28.0",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/client.ts CHANGED
@@ -779,6 +779,7 @@ export class SyncClient {
779
779
  #localResetEpoch = 0;
780
780
  /** Retry policy belongs to the operation that classified the failure. */
781
781
  #retryDelayMs = 250;
782
+ #roundRetryDelayMs: number | undefined;
782
783
  readonly #hasBlobs: boolean;
783
784
  /** §8.6 presence: scopeKey → (peerKey `actorId clientId` → peer). */
784
785
  readonly #presence = new Map<string, Map<string, PresencePeer>>();
@@ -3399,6 +3400,9 @@ export class SyncClient {
3399
3400
  typeof code === 'string'
3400
3401
  ? this.#diagnosticCode(code)
3401
3402
  : 'client.unknown_failure',
3403
+ ...(this.#roundRetryDelayMs !== undefined
3404
+ ? { retryDelayMs: this.#roundRetryDelayMs }
3405
+ : {}),
3402
3406
  });
3403
3407
  this.#lastRound = {
3404
3408
  status: 'failed',
@@ -3454,6 +3458,7 @@ export class SyncClient {
3454
3458
  }
3455
3459
 
3456
3460
  async #runSync(): Promise<SyncSummary> {
3461
+ this.#roundRetryDelayMs = undefined;
3457
3462
  this.#progress.emit({
3458
3463
  attempt: ++this.#progressAttempt,
3459
3464
  state: 'running',
@@ -3601,6 +3606,7 @@ export class SyncClient {
3601
3606
  delayMs: delay,
3602
3607
  };
3603
3608
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
3609
+ this.#roundRetryDelayMs = delay;
3604
3610
  this.#emitSyncIntent(intent);
3605
3611
  }
3606
3612
  throw error;
package/src/errors.ts CHANGED
@@ -35,6 +35,31 @@ export class ClientSyncError extends Error {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * SPEC §7.5: the latest sync attempt failed while a live query still waited
40
+ * for its required coverage. `code` is the attempt's static progress error
41
+ * code; `attempt` is the progress attempt that failed. `retryable` is true when
42
+ * the client scheduled a background retry for the attempt, and `retryDelayMs`
43
+ * is that retry's delay from the failure; a non-retryable failure has no
44
+ * delay and no automatic next attempt. The query returns to waiting when the
45
+ * next attempt starts.
46
+ */
47
+ export class SyncRoundFailedError extends ClientSyncError {
48
+ override readonly name = 'SyncRoundFailedError';
49
+ readonly attempt: number;
50
+ readonly retryDelayMs: number | undefined;
51
+
52
+ constructor(code: string, attempt: number, retryDelayMs: number | undefined) {
53
+ super(
54
+ code,
55
+ 'the sync round failed before the query coverage completed',
56
+ retryDelayMs !== undefined,
57
+ );
58
+ this.attempt = attempt;
59
+ this.retryDelayMs = retryDelayMs;
60
+ }
61
+ }
62
+
38
63
  /**
39
64
  * SPEC §8.8: the `required` realtime policy refused a round because the
40
65
  * socket is not connected. `state` is the availability state that refused
package/src/progress.ts CHANGED
@@ -11,6 +11,8 @@ export interface SyncProgress {
11
11
  readonly rowsProcessed: number;
12
12
  readonly rowsTotal?: number;
13
13
  readonly errorCode?: string;
14
+ /** Present on `failed` when the core scheduled a background retry. */
15
+ readonly retryDelayMs?: number;
14
16
  }
15
17
 
16
18
  export type SyncProgressListener = (progress: SyncProgress) => void;
@@ -11,13 +11,14 @@ import type {
11
11
  WindowState,
12
12
  } from './client';
13
13
  import type { SqlValue } from './database';
14
- import { ClientSyncError } from './errors';
14
+ import { ClientSyncError, SyncRoundFailedError } from './errors';
15
15
  import type {
16
16
  ClientChangeBatch,
17
17
  ClientChangeListener,
18
18
  SyncStatusSnapshot,
19
19
  } from './invalidation';
20
20
  import type { LeadershipState } from './multi-tab';
21
+ import type { SyncProgressListener } from './progress';
21
22
  import { type WindowBase, windowBaseKey } from './window';
22
23
 
23
24
  export interface QueryDependency {
@@ -63,6 +64,7 @@ export interface ReactiveQueryClient extends Pick<
63
64
  ): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
64
65
  leadershipSnapshot?(): LeadershipState | undefined;
65
66
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
67
+ onProgress(listener: SyncProgressListener): () => void;
66
68
  conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
67
69
  rejections(): readonly unknown[] | Promise<readonly unknown[]>;
68
70
  setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
@@ -401,6 +403,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
401
403
  #claimError: Error | undefined;
402
404
  #claimPending = false;
403
405
  #offStatus: (() => void) | undefined;
406
+ #offRound: (() => void) | undefined;
404
407
 
405
408
  constructor(
406
409
  readonly store: ReactiveClientStore,
@@ -425,6 +428,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
425
428
  this.#offStatus = this.store.status.subscribe(() =>
426
429
  this.#onAvailabilityChange(),
427
430
  );
431
+ this.#offRound = this.store.roundFailure.subscribe(() =>
432
+ this.#onRoundFailureChange(),
433
+ );
428
434
  this.#onAvailabilityChange();
429
435
  if (this.spec.claimCoverage !== false) {
430
436
  const claims: Promise<void>[] = [];
@@ -479,6 +485,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
479
485
  this.cache.deactivate(this.key, this);
480
486
  this.#offStatus?.();
481
487
  this.#offStatus = undefined;
488
+ this.#offRound?.();
489
+ this.#offRound = undefined;
482
490
  this.store.releaseWindowClaims(this.#owner);
483
491
  }
484
492
  };
@@ -504,6 +512,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
504
512
  this.#listeners.clear();
505
513
  this.#offStatus?.();
506
514
  this.#offStatus = undefined;
515
+ this.#offRound?.();
516
+ this.#offRound = undefined;
507
517
  this.store.releaseWindowClaims(this.#owner);
508
518
  this.reset();
509
519
  }
@@ -581,6 +591,33 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
581
591
  if (wasBlocked) this.#requestRead();
582
592
  }
583
593
 
594
+ /** SPEC §7.5 failed transfer: incomplete coverage stops waiting on a failed
595
+ * sync attempt and resumes waiting when the next attempt starts. */
596
+ #onRoundFailureChange(): void {
597
+ const failure = this.store.roundFailure.getSnapshot();
598
+ if (failure !== undefined) {
599
+ if (this.#state.phase === 'loading' || this.#state.phase === 'partial')
600
+ this.#publish({
601
+ ...this.#state,
602
+ phase: 'error',
603
+ error: failure,
604
+ isRefreshing: false,
605
+ });
606
+ return;
607
+ }
608
+ if (
609
+ this.#state.phase === 'error' &&
610
+ this.#state.error instanceof SyncRoundFailedError
611
+ ) {
612
+ this.#publish({
613
+ ...this.#state,
614
+ phase: this.#state.rows.length > 0 ? 'partial' : 'loading',
615
+ error: undefined,
616
+ });
617
+ this.#requestRead();
618
+ }
619
+ }
620
+
584
621
  async #readLoop(): Promise<void> {
585
622
  if (this.#running || this.#listeners.size === 0) return;
586
623
  const generation = this.#generation;
@@ -643,16 +680,21 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
643
680
  mappedRows,
644
681
  this.spec.rowKey,
645
682
  );
683
+ const roundFailure = snapshot.coverage.complete
684
+ ? undefined
685
+ : this.store.roundFailure.getSnapshot();
646
686
  const phase: LiveQueryPhase = snapshot.coverage.complete
647
687
  ? 'ready'
648
- : rows.length > 0
649
- ? 'partial'
650
- : 'loading';
688
+ : roundFailure !== undefined
689
+ ? 'error'
690
+ : rows.length > 0
691
+ ? 'partial'
692
+ : 'loading';
651
693
  this.#publish({
652
694
  rows,
653
695
  phase,
654
696
  revision: snapshot.revision,
655
- error: undefined,
697
+ error: roundFailure,
656
698
  isRefreshing: false,
657
699
  availability,
658
700
  });
@@ -819,6 +861,9 @@ export class ReactiveClientStore {
819
861
  readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
820
862
  readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
821
863
  readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
864
+ /** The latest sync attempt's failure until the next attempt starts. */
865
+ readonly roundFailure: ExternalStoreEntry<SyncRoundFailedError | undefined>;
866
+ #offProgress: (() => void) | undefined;
822
867
 
823
868
  constructor(readonly client: ReactiveQueryClient) {
824
869
  const status = new ValueEntry<StatusStoreSnapshot>(
@@ -887,6 +932,13 @@ export class ReactiveClientStore {
887
932
  this.status = status;
888
933
  this.conflicts = conflicts;
889
934
  this.outcomes = outcomes;
935
+ const roundFailure: ValueEntry<SyncRoundFailedError | undefined> =
936
+ new ValueEntry<SyncRoundFailedError | undefined>(
937
+ undefined,
938
+ async (): Promise<SyncRoundFailedError | undefined> =>
939
+ roundFailure.getSnapshot(),
940
+ );
941
+ this.roundFailure = roundFailure;
890
942
  this.start();
891
943
  }
892
944
 
@@ -1094,6 +1146,26 @@ export class ReactiveClientStore {
1094
1146
  }
1095
1147
  if (batch.outcomesChanged) this.outcomes.refresh();
1096
1148
  });
1149
+ const roundFailure = this.roundFailure as ValueEntry<
1150
+ SyncRoundFailedError | undefined
1151
+ >;
1152
+ this.#offProgress = this.client.onProgress((progress) => {
1153
+ const current = roundFailure.getSnapshot();
1154
+ if (progress.state === 'failed' && current?.attempt !== progress.attempt)
1155
+ roundFailure.set(
1156
+ new SyncRoundFailedError(
1157
+ progress.errorCode ?? 'client.unknown_failure',
1158
+ progress.attempt,
1159
+ progress.retryDelayMs,
1160
+ ),
1161
+ );
1162
+ else if (
1163
+ progress.state !== 'failed' &&
1164
+ current !== undefined &&
1165
+ progress.attempt !== current.attempt
1166
+ )
1167
+ roundFailure.set(undefined);
1168
+ });
1097
1169
  this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
1098
1170
  const previous = this.status.getSnapshot();
1099
1171
  (this.status as ValueEntry<StatusStoreSnapshot>).set({
@@ -1117,6 +1189,11 @@ export class ReactiveClientStore {
1117
1189
  this.#offChange = undefined;
1118
1190
  this.#offLeadership?.();
1119
1191
  this.#offLeadership = undefined;
1192
+ this.#offProgress?.();
1193
+ this.#offProgress = undefined;
1194
+ (this.roundFailure as ValueEntry<SyncRoundFailedError | undefined>).set(
1195
+ undefined,
1196
+ );
1120
1197
  for (const group of this.#windowClaims.values()) {
1121
1198
  for (const waiter of group.waiters.splice(0)) {
1122
1199
  waiter.reject(