@syncular/client 0.30.1 → 0.30.2

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.
@@ -233,9 +233,9 @@ class ObservationCache {
233
233
  for (const entry of this.#active)
234
234
  entry.onChange(batch);
235
235
  }
236
- onLeaderServing() {
236
+ reclaim() {
237
237
  for (const entry of this.#active)
238
- entry.onLeaderServing?.();
238
+ entry.reclaim?.();
239
239
  }
240
240
  clear() {
241
241
  for (const entry of this.#entries.values())
@@ -368,6 +368,8 @@ class QueryEntry {
368
368
  refresh = () => {
369
369
  if (this.#delegate !== undefined)
370
370
  this.#delegate.refresh();
371
+ else if (this.#claimError !== undefined)
372
+ this.reclaim();
371
373
  else
372
374
  this.#requestRead(true);
373
375
  };
@@ -390,9 +392,9 @@ class QueryEntry {
390
392
  this.store.releaseWindowClaims(this.#owner);
391
393
  this.reset();
392
394
  }
393
- /** A claim rejected while no leader served this tab (blocked, handover,
394
- * another build) is claimed again; its stored error would fail every read. */
395
- onLeaderServing() {
395
+ /** SPEC §7.7: a rejected claim is claimed again; its stored error would
396
+ * fail every read until the last subscriber leaves. */
397
+ reclaim() {
396
398
  if (this.#delegate !== undefined || this.#claimError === undefined)
397
399
  return;
398
400
  this.#claimError = undefined;
@@ -405,7 +407,14 @@ class QueryEntry {
405
407
  if (batch.revision > this.#desiredRevision) {
406
408
  this.#desiredRevision = batch.revision;
407
409
  }
408
- if (this.#listeners.size > 0)
410
+ if (this.#listeners.size === 0)
411
+ return;
412
+ // A committed row change of a dependency claims again; a window-only
413
+ // batch, which a failed window edit can itself produce, does not.
414
+ const rowChange = batch.tables.some((change) => this.spec.dependencies.some((dependency) => dependency.table === change.table));
415
+ if (rowChange && this.#claimError !== undefined)
416
+ this.reclaim();
417
+ else
409
418
  this.#requestRead();
410
419
  }
411
420
  #publish(next) {
@@ -894,6 +903,16 @@ export class ReactiveClientStore {
894
903
  this.#scheduleWindow(group);
895
904
  }
896
905
  }
906
+ /** Apply every claimed window group again and claim again for query entries
907
+ * whose claim was rejected. An applied group compares equal and makes no
908
+ * call; a group whose edit is in flight applies once more after it. */
909
+ #reclaimWindows() {
910
+ for (const group of this.#windowClaims.values()) {
911
+ if (group.claims.size > 0)
912
+ this.#scheduleWindow(group);
913
+ }
914
+ this.#queries.reclaim();
915
+ }
897
916
  #scheduleWindow(group) {
898
917
  group.requested = true;
899
918
  if (group.scheduled || group.running)
@@ -985,7 +1004,14 @@ export class ReactiveClientStore {
985
1004
  this.outcomes.refresh();
986
1005
  });
987
1006
  const roundFailure = this.roundFailure;
1007
+ let claimedAttempt;
988
1008
  this.#offProgress = this.client.onProgress((progress) => {
1009
+ // A new sync attempt is the client's retry schedule (§7.6): window
1010
+ // claims that failed are claimed again once per attempt.
1011
+ if (progress.state === 'running' && progress.attempt !== claimedAttempt) {
1012
+ claimedAttempt = progress.attempt;
1013
+ this.#reclaimWindows();
1014
+ }
989
1015
  const current = roundFailure.getSnapshot();
990
1016
  if (progress.state === 'failed' && current?.attempt !== progress.attempt)
991
1017
  roundFailure.set(new SyncRoundFailedError(progress.errorCode ?? 'client.unknown_failure', progress.attempt, progress.retryDelayMs));
@@ -1004,13 +1030,8 @@ export class ReactiveClientStore {
1004
1030
  this.status.refresh();
1005
1031
  // Window edits that failed while no leader served this tab apply now;
1006
1032
  // an applied group compares equal and makes no call.
1007
- if (leadership.state === 'leader' || leadership.state === 'follower') {
1008
- for (const group of this.#windowClaims.values()) {
1009
- if (group.claims.size > 0)
1010
- this.#scheduleWindow(group);
1011
- }
1012
- this.#queries.onLeaderServing();
1013
- }
1033
+ if (leadership.state === 'leader' || leadership.state === 'follower')
1034
+ this.#reclaimWindows();
1014
1035
  });
1015
1036
  this.status.refresh();
1016
1037
  this.conflicts.refresh();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
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.30.1"
92
+ "@syncular/core": "0.30.2"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.1",
95
+ "@syncular/server": "0.30.2",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
@@ -316,7 +316,7 @@ function reconcileRows<Row>(
316
316
  interface CachedObservation {
317
317
  onChange(batch: ClientChangeBatch): void;
318
318
  /** A leader serves this tab again (it leads or follows a bound leader). */
319
- onLeaderServing?(): void;
319
+ reclaim?(): void;
320
320
  reset(): void;
321
321
  dispose(): void;
322
322
  }
@@ -358,8 +358,8 @@ class ObservationCache {
358
358
  onChange(batch: ClientChangeBatch): void {
359
359
  for (const entry of this.#active) entry.onChange(batch);
360
360
  }
361
- onLeaderServing(): void {
362
- for (const entry of this.#active) entry.onLeaderServing?.();
361
+ reclaim(): void {
362
+ for (const entry of this.#active) entry.reclaim?.();
363
363
  }
364
364
  clear(): void {
365
365
  for (const entry of this.#entries.values()) entry.dispose();
@@ -504,6 +504,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
504
504
 
505
505
  refresh = (): void => {
506
506
  if (this.#delegate !== undefined) this.#delegate.refresh();
507
+ else if (this.#claimError !== undefined) this.reclaim();
507
508
  else this.#requestRead(true);
508
509
  };
509
510
 
@@ -528,9 +529,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
528
529
  this.reset();
529
530
  }
530
531
 
531
- /** A claim rejected while no leader served this tab (blocked, handover,
532
- * another build) is claimed again; its stored error would fail every read. */
533
- onLeaderServing(): void {
532
+ /** SPEC §7.7: a rejected claim is claimed again; its stored error would
533
+ * fail every read until the last subscriber leaves. */
534
+ reclaim(): void {
534
535
  if (this.#delegate !== undefined || this.#claimError === undefined) return;
535
536
  this.#claimError = undefined;
536
537
  this.#claimCoverage();
@@ -542,7 +543,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
542
543
  if (batch.revision > this.#desiredRevision) {
543
544
  this.#desiredRevision = batch.revision;
544
545
  }
545
- if (this.#listeners.size > 0) this.#requestRead();
546
+ if (this.#listeners.size === 0) return;
547
+ // A committed row change of a dependency claims again; a window-only
548
+ // batch, which a failed window edit can itself produce, does not.
549
+ const rowChange = batch.tables.some((change) =>
550
+ this.spec.dependencies.some(
551
+ (dependency) => dependency.table === change.table,
552
+ ),
553
+ );
554
+ if (rowChange && this.#claimError !== undefined) this.reclaim();
555
+ else this.#requestRead();
546
556
  }
547
557
 
548
558
  #publish(next: LiveQueryResult<Row>): void {
@@ -1078,6 +1088,16 @@ export class ReactiveClientStore {
1078
1088
  }
1079
1089
  }
1080
1090
 
1091
+ /** Apply every claimed window group again and claim again for query entries
1092
+ * whose claim was rejected. An applied group compares equal and makes no
1093
+ * call; a group whose edit is in flight applies once more after it. */
1094
+ #reclaimWindows(): void {
1095
+ for (const group of this.#windowClaims.values()) {
1096
+ if (group.claims.size > 0) this.#scheduleWindow(group);
1097
+ }
1098
+ this.#queries.reclaim();
1099
+ }
1100
+
1081
1101
  #scheduleWindow(group: WindowClaimGroup): void {
1082
1102
  group.requested = true;
1083
1103
  if (group.scheduled || group.running) return;
@@ -1166,7 +1186,14 @@ export class ReactiveClientStore {
1166
1186
  const roundFailure = this.roundFailure as ValueEntry<
1167
1187
  SyncRoundFailedError | undefined
1168
1188
  >;
1189
+ let claimedAttempt: number | undefined;
1169
1190
  this.#offProgress = this.client.onProgress((progress) => {
1191
+ // A new sync attempt is the client's retry schedule (§7.6): window
1192
+ // claims that failed are claimed again once per attempt.
1193
+ if (progress.state === 'running' && progress.attempt !== claimedAttempt) {
1194
+ claimedAttempt = progress.attempt;
1195
+ this.#reclaimWindows();
1196
+ }
1170
1197
  const current = roundFailure.getSnapshot();
1171
1198
  if (progress.state === 'failed' && current?.attempt !== progress.attempt)
1172
1199
  roundFailure.set(
@@ -1192,12 +1219,8 @@ export class ReactiveClientStore {
1192
1219
  if (previous.isLoading) this.status.refresh();
1193
1220
  // Window edits that failed while no leader served this tab apply now;
1194
1221
  // an applied group compares equal and makes no call.
1195
- if (leadership.state === 'leader' || leadership.state === 'follower') {
1196
- for (const group of this.#windowClaims.values()) {
1197
- if (group.claims.size > 0) this.#scheduleWindow(group);
1198
- }
1199
- this.#queries.onLeaderServing();
1200
- }
1222
+ if (leadership.state === 'leader' || leadership.state === 'follower')
1223
+ this.#reclaimWindows();
1201
1224
  });
1202
1225
  this.status.refresh();
1203
1226
  this.conflicts.refresh();