@syncular/client 0.30.0 → 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.
package/README.md CHANGED
@@ -221,7 +221,8 @@ with `client.follower_timeout` when the link goes `blocked`, with
221
221
  `client.leader_handover` when another leader announces or this tab promotes
222
222
  before the answer arrives, and with `client.worker_failed` when the handle
223
223
  closes. Only calls queued while no leader is bound keep the
224
- `followerCallTimeoutMs` deadline.
224
+ `followerCallTimeoutMs` deadline; when this tab wins the lock itself, its own
225
+ core runs them.
225
226
 
226
227
  **Tabs of different builds never serve each other.** Every `hello`, `req`,
227
228
  and `announce` carries `MULTI_TAB_PROTOCOL_VERSION` and the schema version.
@@ -192,6 +192,11 @@ export declare class FollowerLink {
192
192
  * flushes the queued ones.
193
193
  */
194
194
  unbind(): void;
195
+ /**
196
+ * This tab won the lock and runs its own core: calls queued for the next
197
+ * leader run on `invoke` instead of failing, then the link closes.
198
+ */
199
+ handOver(invoke: (method: string, args: readonly unknown[]) => Promise<unknown>): void;
195
200
  close(): void;
196
201
  }
197
202
  export {};
package/dist/multi-tab.js CHANGED
@@ -502,6 +502,19 @@ export class FollowerLink {
502
502
  this.#armUnboundDeadline();
503
503
  this.#probe();
504
504
  }
505
+ /**
506
+ * This tab won the lock and runs its own core: calls queued for the next
507
+ * leader run on `invoke` instead of failing, then the link closes.
508
+ */
509
+ handOver(invoke) {
510
+ const queued = this.#queue;
511
+ this.#queue = [];
512
+ for (const call of queued) {
513
+ call.cancelTimer?.();
514
+ invoke(call.method, call.args).then(call.resolve, call.reject);
515
+ }
516
+ this.close();
517
+ }
505
518
  close() {
506
519
  if (this.#closed)
507
520
  return;
@@ -233,6 +233,10 @@ class ObservationCache {
233
233
  for (const entry of this.#active)
234
234
  entry.onChange(batch);
235
235
  }
236
+ reclaim() {
237
+ for (const entry of this.#active)
238
+ entry.reclaim?.();
239
+ }
236
240
  clear() {
237
241
  for (const entry of this.#entries.values())
238
242
  entry.dispose();
@@ -364,6 +368,8 @@ class QueryEntry {
364
368
  refresh = () => {
365
369
  if (this.#delegate !== undefined)
366
370
  this.#delegate.refresh();
371
+ else if (this.#claimError !== undefined)
372
+ this.reclaim();
367
373
  else
368
374
  this.#requestRead(true);
369
375
  };
@@ -386,13 +392,29 @@ class QueryEntry {
386
392
  this.store.releaseWindowClaims(this.#owner);
387
393
  this.reset();
388
394
  }
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() {
398
+ if (this.#delegate !== undefined || this.#claimError === undefined)
399
+ return;
400
+ this.#claimError = undefined;
401
+ this.#claimCoverage();
402
+ this.#requestRead();
403
+ }
389
404
  onChange(batch) {
390
405
  if (!batchMatches(batch, this.spec))
391
406
  return;
392
407
  if (batch.revision > this.#desiredRevision) {
393
408
  this.#desiredRevision = batch.revision;
394
409
  }
395
- 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
396
418
  this.#requestRead();
397
419
  }
398
420
  #publish(next) {
@@ -446,12 +468,6 @@ class QueryEntry {
446
468
  return;
447
469
  }
448
470
  const wasBlocked = this.#state.phase === 'blocked';
449
- // A claim that failed while the leader link was blocked is retried with
450
- // the link: its stored error would otherwise fail every later read.
451
- if (wasBlocked && this.#claimError !== undefined) {
452
- this.#claimError = undefined;
453
- this.#claimCoverage();
454
- }
455
471
  this.#publish({
456
472
  ...this.#state,
457
473
  phase: wasBlocked
@@ -887,6 +903,16 @@ export class ReactiveClientStore {
887
903
  this.#scheduleWindow(group);
888
904
  }
889
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
+ }
890
916
  #scheduleWindow(group) {
891
917
  group.requested = true;
892
918
  if (group.scheduled || group.running)
@@ -978,7 +1004,14 @@ export class ReactiveClientStore {
978
1004
  this.outcomes.refresh();
979
1005
  });
980
1006
  const roundFailure = this.roundFailure;
1007
+ let claimedAttempt;
981
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
+ }
982
1015
  const current = roundFailure.getSnapshot();
983
1016
  if (progress.state === 'failed' && current?.attempt !== progress.attempt)
984
1017
  roundFailure.set(new SyncRoundFailedError(progress.errorCode ?? 'client.unknown_failure', progress.attempt, progress.retryDelayMs));
@@ -997,12 +1030,8 @@ export class ReactiveClientStore {
997
1030
  this.status.refresh();
998
1031
  // Window edits that failed while no leader served this tab apply now;
999
1032
  // an applied group compares equal and makes no call.
1000
- if (leadership.state === 'leader' || leadership.state === 'follower') {
1001
- for (const group of this.#windowClaims.values()) {
1002
- if (group.claims.size > 0)
1003
- this.#scheduleWindow(group);
1004
- }
1005
- }
1033
+ if (leadership.state === 'leader' || leadership.state === 'follower')
1034
+ this.#reclaimWindows();
1006
1035
  });
1007
1036
  this.status.refresh();
1008
1037
  this.conflicts.refresh();
@@ -128,7 +128,8 @@ export class SyncClientHandle {
128
128
  }
129
129
  /** @internal — swap this handle from follower to leader (promotion). */
130
130
  __becomeLeader(core) {
131
- this.#follower?.close();
131
+ // Calls queued for the next leader run on this tab's own core.
132
+ this.#follower?.handOver(core.invoke);
132
133
  this.#follower = undefined;
133
134
  this.#core = core;
134
135
  this.#clientId = core.clientId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.30.0",
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.0"
92
+ "@syncular/core": "0.30.2"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.30.0",
95
+ "@syncular/server": "0.30.2",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
package/src/multi-tab.ts CHANGED
@@ -724,6 +724,22 @@ export class FollowerLink {
724
724
  this.#probe();
725
725
  }
726
726
 
727
+ /**
728
+ * This tab won the lock and runs its own core: calls queued for the next
729
+ * leader run on `invoke` instead of failing, then the link closes.
730
+ */
731
+ handOver(
732
+ invoke: (method: string, args: readonly unknown[]) => Promise<unknown>,
733
+ ): void {
734
+ const queued = this.#queue;
735
+ this.#queue = [];
736
+ for (const call of queued) {
737
+ call.cancelTimer?.();
738
+ invoke(call.method, call.args).then(call.resolve, call.reject);
739
+ }
740
+ this.close();
741
+ }
742
+
727
743
  close(): void {
728
744
  if (this.#closed) return;
729
745
  this.#closed = true;
@@ -315,6 +315,8 @@ function reconcileRows<Row>(
315
315
 
316
316
  interface CachedObservation {
317
317
  onChange(batch: ClientChangeBatch): void;
318
+ /** A leader serves this tab again (it leads or follows a bound leader). */
319
+ reclaim?(): void;
318
320
  reset(): void;
319
321
  dispose(): void;
320
322
  }
@@ -356,6 +358,9 @@ class ObservationCache {
356
358
  onChange(batch: ClientChangeBatch): void {
357
359
  for (const entry of this.#active) entry.onChange(batch);
358
360
  }
361
+ reclaim(): void {
362
+ for (const entry of this.#active) entry.reclaim?.();
363
+ }
359
364
  clear(): void {
360
365
  for (const entry of this.#entries.values()) entry.dispose();
361
366
  this.#entries.clear();
@@ -499,6 +504,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
499
504
 
500
505
  refresh = (): void => {
501
506
  if (this.#delegate !== undefined) this.#delegate.refresh();
507
+ else if (this.#claimError !== undefined) this.reclaim();
502
508
  else this.#requestRead(true);
503
509
  };
504
510
 
@@ -523,12 +529,30 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
523
529
  this.reset();
524
530
  }
525
531
 
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 {
535
+ if (this.#delegate !== undefined || this.#claimError === undefined) return;
536
+ this.#claimError = undefined;
537
+ this.#claimCoverage();
538
+ this.#requestRead();
539
+ }
540
+
526
541
  onChange(batch: ClientChangeBatch): void {
527
542
  if (!batchMatches(batch, this.spec)) return;
528
543
  if (batch.revision > this.#desiredRevision) {
529
544
  this.#desiredRevision = batch.revision;
530
545
  }
531
- 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();
532
556
  }
533
557
 
534
558
  #publish(next: LiveQueryResult<Row>): void {
@@ -584,12 +608,6 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
584
608
  return;
585
609
  }
586
610
  const wasBlocked = this.#state.phase === 'blocked';
587
- // A claim that failed while the leader link was blocked is retried with
588
- // the link: its stored error would otherwise fail every later read.
589
- if (wasBlocked && this.#claimError !== undefined) {
590
- this.#claimError = undefined;
591
- this.#claimCoverage();
592
- }
593
611
  this.#publish({
594
612
  ...this.#state,
595
613
  phase: wasBlocked
@@ -1070,6 +1088,16 @@ export class ReactiveClientStore {
1070
1088
  }
1071
1089
  }
1072
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
+
1073
1101
  #scheduleWindow(group: WindowClaimGroup): void {
1074
1102
  group.requested = true;
1075
1103
  if (group.scheduled || group.running) return;
@@ -1158,7 +1186,14 @@ export class ReactiveClientStore {
1158
1186
  const roundFailure = this.roundFailure as ValueEntry<
1159
1187
  SyncRoundFailedError | undefined
1160
1188
  >;
1189
+ let claimedAttempt: number | undefined;
1161
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
+ }
1162
1197
  const current = roundFailure.getSnapshot();
1163
1198
  if (progress.state === 'failed' && current?.attempt !== progress.attempt)
1164
1199
  roundFailure.set(
@@ -1184,11 +1219,8 @@ export class ReactiveClientStore {
1184
1219
  if (previous.isLoading) this.status.refresh();
1185
1220
  // Window edits that failed while no leader served this tab apply now;
1186
1221
  // an applied group compares equal and makes no call.
1187
- if (leadership.state === 'leader' || leadership.state === 'follower') {
1188
- for (const group of this.#windowClaims.values()) {
1189
- if (group.claims.size > 0) this.#scheduleWindow(group);
1190
- }
1191
- }
1222
+ if (leadership.state === 'leader' || leadership.state === 'follower')
1223
+ this.#reclaimWindows();
1192
1224
  });
1193
1225
  this.status.refresh();
1194
1226
  this.conflicts.refresh();
@@ -385,7 +385,8 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
385
385
 
386
386
  /** @internal — swap this handle from follower to leader (promotion). */
387
387
  __becomeLeader(core: LeaderCore): void {
388
- this.#follower?.close();
388
+ // Calls queued for the next leader run on this tab's own core.
389
+ this.#follower?.handOver(core.invoke);
389
390
  this.#follower = undefined;
390
391
  this.#core = core;
391
392
  this.#clientId = core.clientId;