@syncular/client 0.15.48 → 0.17.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.
@@ -4,12 +4,14 @@ import {
4
4
  } from './availability';
5
5
  import type {
6
6
  CommitOutcome,
7
+ ClientSnapshotReader,
7
8
  QueryReadSpec,
8
9
  QuerySnapshot,
9
10
  WindowCoverage,
10
11
  WindowState,
11
12
  } from './client';
12
13
  import type { SqlValue } from './database';
14
+ import { ClientSyncError } from './errors';
13
15
  import type {
14
16
  ClientChangeBatch,
15
17
  ClientChangeListener,
@@ -50,24 +52,19 @@ export interface LiveQueryResult<Row> {
50
52
  readonly availability: SyncAvailability;
51
53
  }
52
54
 
53
- export interface ReactiveQueryClient {
55
+ export interface ReactiveQueryClient extends Pick<
56
+ ClientSnapshotReader,
57
+ 'statusSnapshot' | 'commitOutcomes'
58
+ > {
54
59
  readonly currentSchemaVersion?: number;
55
60
  onChange(listener: ClientChangeListener): () => void;
56
61
  querySnapshot<Row = Record<string, SqlValue>>(
57
62
  spec: QueryReadSpec,
58
63
  ): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
59
- statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
60
64
  leadershipSnapshot?(): LeadershipState | undefined;
61
65
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
62
- readonly conflicts:
63
- | readonly unknown[]
64
- | (() => readonly unknown[] | Promise<readonly unknown[]>);
65
- readonly rejections:
66
- | readonly unknown[]
67
- | (() => readonly unknown[] | Promise<readonly unknown[]>);
68
- commitOutcomes():
69
- | readonly CommitOutcome[]
70
- | Promise<readonly CommitOutcome[]>;
66
+ conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
67
+ rejections(): readonly unknown[] | Promise<readonly unknown[]>;
71
68
  setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
72
69
  windowState(base: WindowBase): WindowState | Promise<WindowState>;
73
70
  }
@@ -112,14 +109,6 @@ function errorOf(value: unknown): Error {
112
109
  return value instanceof Error ? value : new Error(String(value));
113
110
  }
114
111
 
115
- function readCollection(
116
- value:
117
- | readonly unknown[]
118
- | (() => readonly unknown[] | Promise<readonly unknown[]>),
119
- ): readonly unknown[] | Promise<readonly unknown[]> {
120
- return typeof value === 'function' ? value() : value;
121
- }
122
-
123
112
  function unsupportedCanonicalValue(value: unknown): never {
124
113
  const description = Object.prototype.toString.call(value);
125
114
  throw new TypeError(
@@ -303,6 +292,56 @@ function reconcileRows<Row>(
303
292
  : next;
304
293
  }
305
294
 
295
+ interface CachedObservation {
296
+ onChange(batch: ClientChangeBatch): void;
297
+ reset(): void;
298
+ dispose(): void;
299
+ }
300
+
301
+ /** Shared ownership for query and window observations, including abandoned renders. */
302
+ class ObservationCache {
303
+ readonly #entries = new Map<string, CachedObservation>();
304
+ readonly #active = new Set<CachedObservation>();
305
+ get size(): number {
306
+ return this.#entries.size;
307
+ }
308
+ get activeSize(): number {
309
+ return this.#active.size;
310
+ }
311
+ get(key: string): CachedObservation | undefined {
312
+ return this.#entries.get(key);
313
+ }
314
+ add(key: string, entry: CachedObservation): void {
315
+ this.#entries.set(key, entry);
316
+ this.#cleanup(key, entry);
317
+ }
318
+ activate(key: string, candidate: CachedObservation): CachedObservation {
319
+ const entry = this.#entries.get(key) ?? candidate;
320
+ this.#entries.set(key, entry);
321
+ this.#active.add(entry);
322
+ return entry;
323
+ }
324
+ deactivate(key: string, entry: CachedObservation): void {
325
+ this.#active.delete(entry);
326
+ this.#cleanup(key, entry);
327
+ }
328
+ #cleanup(key: string, entry: CachedObservation): void {
329
+ scheduleMicrotask(() => {
330
+ if (this.#active.has(entry)) return;
331
+ if (this.#entries.get(key) === entry) this.#entries.delete(key);
332
+ entry.reset();
333
+ });
334
+ }
335
+ onChange(batch: ClientChangeBatch): void {
336
+ for (const entry of this.#active) entry.onChange(batch);
337
+ }
338
+ clear(): void {
339
+ for (const entry of this.#entries.values()) entry.dispose();
340
+ this.#entries.clear();
341
+ this.#active.clear();
342
+ }
343
+ }
344
+
306
345
  class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
307
346
  readonly #owner = Symbol('query-window-claim');
308
347
  readonly #listeners = new Set<() => void>();
@@ -314,7 +353,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
314
353
  isRefreshing: false,
315
354
  availability: { state: 'ready' },
316
355
  };
317
- #subscribers = 0;
356
+ #delegate: QueryEntry<Row> | undefined;
357
+ #generation = 0;
318
358
  #scheduled = false;
319
359
  #running = false;
320
360
  #requested = false;
@@ -325,14 +365,23 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
325
365
  constructor(
326
366
  readonly store: ReactiveClientStore,
327
367
  readonly spec: ReactiveQuerySpec<Row>,
368
+ private readonly key: string,
369
+ private readonly cache: ObservationCache,
328
370
  ) {}
329
371
 
330
- getSnapshot = (): LiveQueryResult<Row> => this.#state;
372
+ getSnapshot = (): LiveQueryResult<Row> =>
373
+ this.#delegate?.getSnapshot() ?? this.#state;
331
374
 
332
375
  subscribe = (listener: () => void): (() => void) => {
333
- this.#listeners.add(listener);
334
- this.#subscribers += 1;
335
- if (this.#subscribers === 1) {
376
+ const current = this.cache.activate(this.key, this);
377
+ if (current !== this) {
378
+ this.#delegate = current as QueryEntry<Row>;
379
+ return this.#delegate.subscribe(listener);
380
+ }
381
+ this.#delegate = undefined;
382
+ const notify = () => listener();
383
+ this.#listeners.add(notify);
384
+ if (this.#listeners.size === 1) {
336
385
  this.#offStatus = this.store.status.subscribe(() =>
337
386
  this.#onAvailabilityChange(),
338
387
  );
@@ -349,13 +398,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
349
398
  );
350
399
  }
351
400
  this.#claimReady = Promise.all(claims).then(() => undefined);
401
+ // A render can lose its last subscriber before the read loop starts.
402
+ // The loop still observes the original rejection while it owns the claim.
403
+ void this.#claimReady.catch(() => undefined);
352
404
  }
353
405
  this.#requestRead();
354
406
  }
355
407
  return () => {
356
- if (!this.#listeners.delete(listener)) return;
357
- this.#subscribers -= 1;
358
- if (this.#subscribers === 0) {
408
+ if (!this.#listeners.delete(notify)) return;
409
+ if (this.#listeners.size === 0) {
410
+ this.cache.deactivate(this.key, this);
359
411
  this.#offStatus?.();
360
412
  this.#offStatus = undefined;
361
413
  this.store.releaseWindowClaims(this.#owner);
@@ -363,14 +415,42 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
363
415
  };
364
416
  };
365
417
 
366
- refresh = (): void => this.#requestRead(true);
418
+ refresh = (): void => {
419
+ if (this.#delegate !== undefined) this.#delegate.refresh();
420
+ else this.#requestRead(true);
421
+ };
422
+
423
+ reset(): void {
424
+ this.#generation += 1;
425
+ this.#scheduled = false;
426
+ this.#running = false;
427
+ this.#requested = false;
428
+ this.#desiredRevision = 0n;
429
+ this.#claimReady = Promise.resolve();
430
+ this.#state = {
431
+ rows: [],
432
+ phase: 'loading',
433
+ revision: undefined,
434
+ error: undefined,
435
+ isRefreshing: false,
436
+ availability: { state: 'ready' },
437
+ };
438
+ }
439
+
440
+ dispose(): void {
441
+ this.#listeners.clear();
442
+ this.#offStatus?.();
443
+ this.#offStatus = undefined;
444
+ this.store.releaseWindowClaims(this.#owner);
445
+ this.reset();
446
+ }
367
447
 
368
448
  onChange(batch: ClientChangeBatch): void {
369
449
  if (!batchMatches(batch, this.spec)) return;
370
450
  if (batch.revision > this.#desiredRevision) {
371
451
  this.#desiredRevision = batch.revision;
372
452
  }
373
- if (this.#subscribers > 0) this.#requestRead();
453
+ if (this.#listeners.size > 0) this.#requestRead();
374
454
  }
375
455
 
376
456
  #publish(next: LiveQueryResult<Row>): void {
@@ -389,6 +469,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
389
469
  }
390
470
 
391
471
  #requestRead(refreshing = false): void {
472
+ if (this.#listeners.size === 0) return;
392
473
  this.#requested = true;
393
474
  if (
394
475
  refreshing &&
@@ -399,7 +480,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
399
480
  }
400
481
  if (this.#scheduled || this.#running) return;
401
482
  this.#scheduled = true;
483
+ const generation = this.#generation;
402
484
  scheduleMicrotask(() => {
485
+ if (generation !== this.#generation) return;
403
486
  this.#scheduled = false;
404
487
  void this.#readLoop();
405
488
  });
@@ -430,13 +513,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
430
513
  }
431
514
 
432
515
  async #readLoop(): Promise<void> {
433
- if (this.#running || this.#subscribers === 0) return;
516
+ if (this.#running || this.#listeners.size === 0) return;
517
+ const generation = this.#generation;
434
518
  this.#running = true;
435
519
  try {
436
520
  do {
437
521
  this.#requested = false;
438
522
  if (this.store.availabilitySnapshot().state === 'blocked') break;
439
523
  await this.#claimReady;
524
+ if (generation !== this.#generation || this.#listeners.size === 0)
525
+ return;
440
526
  const snapshot = await this.store.client.querySnapshot<
441
527
  Record<string, SqlValue>
442
528
  >({
@@ -448,6 +534,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
448
534
  ? { coverage: this.spec.coverage }
449
535
  : {}),
450
536
  });
537
+ if (generation !== this.#generation || this.#listeners.size === 0)
538
+ return;
451
539
  const availability = this.store.availabilitySnapshot();
452
540
  if (availability.state === 'blocked') {
453
541
  this.#publish({
@@ -484,8 +572,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
484
572
  isRefreshing: false,
485
573
  availability,
486
574
  });
487
- } while (this.#requested && this.#subscribers > 0);
575
+ } while (this.#requested && this.#listeners.size > 0);
488
576
  } catch (error) {
577
+ if (generation !== this.#generation || this.#listeners.size === 0) return;
489
578
  const wrapped = errorOf(error);
490
579
  this.#publish({
491
580
  ...this.#state,
@@ -495,13 +584,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
495
584
  availability: this.store.availabilitySnapshot(),
496
585
  });
497
586
  } finally {
498
- this.#running = false;
499
- if (this.#requested && this.#subscribers > 0) this.#requestRead();
587
+ if (generation === this.#generation) {
588
+ this.#running = false;
589
+ if (this.#requested && this.#listeners.size > 0) this.#requestRead();
590
+ }
500
591
  }
501
592
  }
502
593
  }
503
594
 
504
595
  class ValueEntry<T> implements ExternalStoreEntry<T> {
596
+ #generation = 0;
505
597
  readonly #listeners = new Set<() => void>();
506
598
  constructor(
507
599
  private value: T,
@@ -513,9 +605,16 @@ class ValueEntry<T> implements ExternalStoreEntry<T> {
513
605
  return () => this.#listeners.delete(listener);
514
606
  };
515
607
  refresh = (): void => {
516
- void this.read().then((next) => this.set(next));
608
+ const generation = ++this.#generation;
609
+ void this.read().then((next) => {
610
+ if (generation === this.#generation) this.set(next);
611
+ });
517
612
  };
613
+ invalidate(): void {
614
+ this.#generation += 1;
615
+ }
518
616
  set(next: T): void {
617
+ this.invalidate();
519
618
  if (next === this.value) return;
520
619
  this.value = next;
521
620
  for (const listener of this.#listeners) listener();
@@ -538,6 +637,8 @@ interface WindowClaimGroup {
538
637
  class WindowEntry implements ExternalStoreEntry<WindowState> {
539
638
  readonly #listeners = new Set<() => void>();
540
639
  #state: WindowState = { units: [], pending: [] };
640
+ #generation = 0;
641
+ #delegate: WindowEntry | undefined;
541
642
  #running = false;
542
643
  #requested = false;
543
644
 
@@ -545,19 +646,46 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
545
646
  readonly store: ReactiveClientStore,
546
647
  readonly base: WindowBase,
547
648
  readonly baseKey: string,
649
+ private readonly cache: ObservationCache,
548
650
  ) {}
549
651
 
550
- getSnapshot = (): WindowState => this.#state;
652
+ getSnapshot = (): WindowState => this.#delegate?.getSnapshot() ?? this.#state;
551
653
  subscribe = (listener: () => void): (() => void) => {
552
- this.#listeners.add(listener);
654
+ const current = this.cache.activate(this.baseKey, this);
655
+ if (current !== this) {
656
+ this.#delegate = current as WindowEntry;
657
+ return this.#delegate.subscribe(listener);
658
+ }
659
+ this.#delegate = undefined;
660
+ const notify = () => listener();
661
+ this.#listeners.add(notify);
553
662
  if (this.#listeners.size === 1) this.refresh();
554
- return () => this.#listeners.delete(listener);
663
+ return () => {
664
+ if (this.#listeners.delete(notify) && this.#listeners.size === 0) {
665
+ this.cache.deactivate(this.baseKey, this);
666
+ }
667
+ };
555
668
  };
556
669
  refresh = (): void => {
670
+ if (this.#delegate !== undefined) {
671
+ this.#delegate.refresh();
672
+ return;
673
+ }
674
+ if (this.#listeners.size === 0) return;
557
675
  this.#requested = true;
558
676
  if (this.#running) return;
559
677
  void this.#readLoop();
560
678
  };
679
+ reset(): void {
680
+ this.#generation += 1;
681
+ this.#running = false;
682
+ this.#requested = false;
683
+ this.#state = { units: [], pending: [] };
684
+ }
685
+ dispose(): void {
686
+ this.#listeners.clear();
687
+ this.reset();
688
+ }
561
689
  onChange(batch: ClientChangeBatch): void {
562
690
  if (
563
691
  batch.windows.some((change) => change.baseKey === this.baseKey) &&
@@ -567,11 +695,14 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
567
695
  }
568
696
  }
569
697
  async #readLoop(): Promise<void> {
698
+ const generation = this.#generation;
570
699
  this.#running = true;
571
700
  try {
572
701
  do {
573
702
  this.#requested = false;
574
703
  const next = await this.store.client.windowState(this.base);
704
+ if (generation !== this.#generation || this.#listeners.size === 0)
705
+ return;
575
706
  if (
576
707
  canonicalValue(next.units) !== canonicalValue(this.#state.units) ||
577
708
  canonicalValue(next.pending) !== canonicalValue(this.#state.pending)
@@ -579,20 +710,22 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
579
710
  this.#state = next;
580
711
  for (const listener of this.#listeners) listener();
581
712
  }
582
- } while (this.#requested);
713
+ } while (this.#requested && this.#listeners.size > 0);
583
714
  } catch {
584
715
  // WindowState predates the error-bearing query result. Keep the last
585
716
  // coherent snapshot; a later exact window event or refresh retries.
586
717
  } finally {
587
- this.#running = false;
588
- if (this.#requested) this.refresh();
718
+ if (generation === this.#generation) {
719
+ this.#running = false;
720
+ if (this.#requested) this.refresh();
721
+ }
589
722
  }
590
723
  }
591
724
  }
592
725
 
593
726
  export class ReactiveClientStore {
594
- readonly #queries = new Map<string, QueryEntry<unknown>>();
595
- readonly #windows = new Map<string, WindowEntry>();
727
+ readonly #queries = new ObservationCache();
728
+ readonly #windows = new ObservationCache();
596
729
  readonly #windowClaims = new Map<string, WindowClaimGroup>();
597
730
  #offChange: (() => void) | undefined;
598
731
  #offLeadership: (() => void) | undefined;
@@ -631,8 +764,8 @@ export class ReactiveClientStore {
631
764
  async () => {
632
765
  try {
633
766
  const [found, rejected] = await Promise.all([
634
- readCollection(client.conflicts),
635
- readCollection(client.rejections),
767
+ client.conflicts(),
768
+ client.rejections(),
636
769
  ]);
637
770
  return {
638
771
  conflicts: found,
@@ -667,9 +800,6 @@ export class ReactiveClientStore {
667
800
  this.status = status;
668
801
  this.conflicts = conflicts;
669
802
  this.outcomes = outcomes;
670
- status.refresh();
671
- conflicts.refresh();
672
- outcomes.refresh();
673
803
  this.start();
674
804
  }
675
805
 
@@ -700,8 +830,8 @@ export class ReactiveClientStore {
700
830
  });
701
831
  let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
702
832
  if (entry === undefined) {
703
- entry = new QueryEntry(this, spec);
704
- this.#queries.set(key, entry as QueryEntry<unknown>);
833
+ entry = new QueryEntry(this, spec, key, this.#queries);
834
+ this.#queries.add(key, entry);
705
835
  }
706
836
  return entry;
707
837
  }
@@ -739,10 +869,10 @@ export class ReactiveClientStore {
739
869
 
740
870
  window(base: WindowBase): ExternalStoreEntry<WindowState> {
741
871
  const key = windowBaseKey(base);
742
- let entry = this.#windows.get(key);
872
+ let entry = this.#windows.get(key) as WindowEntry | undefined;
743
873
  if (entry === undefined) {
744
- entry = new WindowEntry(this, base, key);
745
- this.#windows.set(key, entry);
874
+ entry = new WindowEntry(this, base, key, this.#windows);
875
+ this.#windows.add(key, entry);
746
876
  }
747
877
  return entry;
748
878
  }
@@ -791,7 +921,8 @@ export class ReactiveClientStore {
791
921
  }
792
922
 
793
923
  async #flushWindow(group: WindowClaimGroup): Promise<void> {
794
- if (group.running) return;
924
+ const baseKey = windowBaseKey(group.base);
925
+ if (group.running || this.#windowClaims.get(baseKey) !== group) return;
795
926
  group.running = true;
796
927
  try {
797
928
  while (group.requested) {
@@ -802,6 +933,7 @@ export class ReactiveClientStore {
802
933
  const key = canonicalValue(units);
803
934
  if (key !== group.appliedKey) {
804
935
  await this.client.setWindow(group.base, units);
936
+ if (this.#windowClaims.get(baseKey) !== group) return;
805
937
  group.appliedKey = key;
806
938
  }
807
939
  }
@@ -810,15 +942,36 @@ export class ReactiveClientStore {
810
942
  for (const waiter of group.waiters.splice(0)) waiter.reject(error);
811
943
  } finally {
812
944
  group.running = false;
813
- if (group.requested) this.#scheduleWindow(group);
945
+ if (group.requested && this.#windowClaims.get(baseKey) === group)
946
+ this.#scheduleWindow(group);
947
+ else if (
948
+ group.claims.size === 0 &&
949
+ group.waiters.length === 0 &&
950
+ group.appliedKey === canonicalValue([])
951
+ ) {
952
+ const key = windowBaseKey(group.base);
953
+ if (this.#windowClaims.get(key) === group)
954
+ this.#windowClaims.delete(key);
955
+ }
814
956
  }
815
957
  }
816
958
 
959
+ /** Retained observation counts for diagnostics and resource benchmarks. */
960
+ cacheStats() {
961
+ return {
962
+ queries: this.#queries.size,
963
+ activeQueries: this.#queries.activeSize,
964
+ windows: this.#windows.size,
965
+ activeWindows: this.#windows.activeSize,
966
+ windowClaims: this.#windowClaims.size,
967
+ };
968
+ }
969
+
817
970
  start(): void {
818
971
  if (this.#offChange !== undefined) return;
819
972
  this.#offChange = this.client.onChange((batch) => {
820
- for (const entry of this.#queries.values()) entry.onChange(batch);
821
- for (const entry of this.#windows.values()) entry.onChange(batch);
973
+ this.#queries.onChange(batch);
974
+ this.#windows.onChange(batch);
822
975
  if (batch.status !== undefined) {
823
976
  (this.status as ValueEntry<StatusStoreSnapshot>).set({
824
977
  status: batch.status,
@@ -838,15 +991,32 @@ export class ReactiveClientStore {
838
991
  ...previous,
839
992
  leadership,
840
993
  });
994
+ if (previous.isLoading) this.status.refresh();
841
995
  });
996
+ this.status.refresh();
997
+ this.conflicts.refresh();
998
+ this.outcomes.refresh();
842
999
  }
843
1000
 
844
1001
  dispose(): void {
1002
+ this.#queries.clear();
1003
+ this.#windows.clear();
1004
+ for (const entry of [this.status, this.conflicts, this.outcomes]) {
1005
+ (entry as ValueEntry<unknown>).invalidate();
1006
+ }
845
1007
  this.#offChange?.();
846
1008
  this.#offChange = undefined;
847
1009
  this.#offLeadership?.();
848
1010
  this.#offLeadership = undefined;
849
1011
  for (const group of this.#windowClaims.values()) {
1012
+ for (const waiter of group.waiters.splice(0)) {
1013
+ waiter.reject(
1014
+ new ClientSyncError(
1015
+ 'client.reactive_store_disposed',
1016
+ 'reactive store disposed',
1017
+ ),
1018
+ );
1019
+ }
850
1020
  // Releasing a window is best-effort teardown. A resource owner may have
851
1021
  // already closed the underlying worker/native handle before React effect
852
1022
  // cleanup runs (notably during schema-changing HMR). Do not let that
@@ -4,9 +4,6 @@ import type {
4
4
  ClientDiagnosticsListener,
5
5
  ClientDiagnosticsSnapshot,
6
6
  } from './diagnostics';
7
- import { realtimeSupervisorObservationSource } from './realtime-supervisor-observation';
8
-
9
- export { linkRealtimeSupervisorObservation } from './realtime-supervisor-observation';
10
7
 
11
8
  type CancelTimer = () => void;
12
9
 
@@ -103,20 +100,14 @@ interface RealtimeSupervisorAttachment {
103
100
  readonly supervisor: RealtimeSupervisor;
104
101
  }
105
102
 
106
- function attachment(
107
- client: object,
108
- visited: Set<object> = new Set(),
109
- ): RealtimeSupervisorAttachment | undefined {
110
- if (visited.has(client)) return undefined;
111
- visited.add(client);
103
+ function attachment(client: object): RealtimeSupervisorAttachment | undefined {
112
104
  const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY) as
113
105
  | Partial<RealtimeSupervisorAttachment>
114
106
  | undefined;
115
107
  if (candidate?.version === 1 && candidate.supervisor) {
116
108
  return candidate as RealtimeSupervisorAttachment;
117
109
  }
118
- const source = realtimeSupervisorObservationSource(client);
119
- return source === undefined ? undefined : attachment(source, visited);
110
+ return undefined;
120
111
  }
121
112
 
122
113
  function scheduleTimer(callback: () => void, delayMs: number): CancelTimer {
@@ -151,7 +151,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
151
151
  if (
152
152
  closed ||
153
153
  client === undefined ||
154
- client.securityLifecycle === 'preflight'
154
+ client.securityLifecycle() === 'preflight'
155
155
  ) {
156
156
  return;
157
157
  }
@@ -175,7 +175,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
175
175
  !autoSync ||
176
176
  closed ||
177
177
  client === undefined ||
178
- client.securityLifecycle === 'preflight' ||
178
+ client.securityLifecycle() === 'preflight' ||
179
179
  intent.kind === 'none'
180
180
  ) {
181
181
  return;
@@ -371,12 +371,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
371
371
  // `start()` may discover persisted subscriptions/outbox work. Its callback
372
372
  // fires before this worker publishes the initialized client, so consume
373
373
  // the durable state once here as well; coalescing makes this a single task.
374
- if (started.syncNeeded) consumeSyncIntent({ kind: 'interactive' });
374
+ if (started.statusSnapshot().syncNeeded)
375
+ consumeSyncIntent({ kind: 'interactive' });
375
376
  return { clientId: started.clientId };
376
377
  }
377
378
 
378
379
  const api: WorkerApi = {
379
- securityLifecycle: () => requireClient().securityLifecycle,
380
+ securityLifecycle: () => requireClient().securityLifecycle(),
380
381
  beginSecurityPreflight: async () => {
381
382
  if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
382
383
  backgroundTimer = undefined;
@@ -435,17 +436,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
435
436
  realtime: snapshot.host.realtime,
436
437
  });
437
438
  },
438
- conflicts: () => requireClient().conflicts,
439
- rejections: () => requireClient().rejections,
439
+ conflicts: () => requireClient().conflicts(),
440
+ rejections: () => requireClient().rejections(),
440
441
  commitOutcome: (clientCommitId) =>
441
442
  requireClient().commitOutcome(clientCommitId),
442
443
  commitOutcomes: (query) => requireClient().commitOutcomes(query),
443
444
  resolveCommitOutcome: (input) =>
444
445
  requireClient().resolveCommitOutcome(input),
445
- schemaFloor: () => requireClient().schemaFloor,
446
- leaseState: () => requireClient().leaseState,
447
- upgrading: () => requireClient().upgrading,
448
- syncNeeded: () => requireClient().syncNeeded,
449
446
  pendingCommits: () => requireClient().pendingCommits(),
450
447
  subscriptions: () => requireClient().subscriptions(),
451
448
  subscription: (id) => requireClient().subscription(id),
@@ -1,3 +1,4 @@
1
+ import type { PromiseMethods } from './client';
1
2
  /**
2
3
  * Main-thread side of the worker mode and the
3
4
  * multi-tab topology.
@@ -25,13 +26,11 @@ import type { WakeReason } from '@syncular/core';
25
26
  import type { BlobRef, CachedBlob } from './blob';
26
27
  import type {
27
28
  ConflictRecord,
28
- LeaseState,
29
29
  MutationInput,
30
30
  PresencePeer,
31
31
  QueryReadSpec,
32
32
  QuerySnapshot,
33
33
  RejectionRecord,
34
- SchemaFloor,
35
34
  SecurityLifecycle,
36
35
  SubscribeInput,
37
36
  SyncClientLimits,
@@ -244,7 +243,7 @@ interface LeaderCore {
244
243
  * promise. `role` is `'leader'` (owns the worker) or `'follower'` (proxies to
245
244
  * the leader over the channel). Constructed via {@link createSyncClientHandle}.
246
245
  */
247
- export class SyncClientHandle {
246
+ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
248
247
  /** True only for a leader handle. Kept for the pre-multiTab contract. */
249
248
  get isLeader(): boolean {
250
249
  return this.#role === 'leader';
@@ -324,12 +323,12 @@ export class SyncClientHandle {
324
323
  ref: this,
325
324
  clientId: () => this.#clientId,
326
325
  role: () => this.#role,
327
- outbox: async () => (await this.pendingCommits()).length,
326
+ outbox: async () => (await this.statusSnapshot()).outbox,
328
327
  subscriptions: () => this.subscriptions(),
329
328
  conflicts: async () => (await this.conflicts()).length,
330
329
  rejections: async () => (await this.rejections()).length,
331
- syncNeeded: () => this.syncNeeded(),
332
- upgrading: () => this.upgrading(),
330
+ syncNeeded: async () => (await this.statusSnapshot()).syncNeeded,
331
+ upgrading: async () => (await this.statusSnapshot()).upgrading,
333
332
  onInvalidate: (listener) => this.onInvalidate(listener),
334
333
  });
335
334
  }
@@ -593,23 +592,7 @@ export class SyncClientHandle {
593
592
  return this.#call('resolveCommitOutcome', [input]);
594
593
  }
595
594
 
596
- schemaFloor(): Promise<SchemaFloor | undefined> {
597
- return this.#call('schemaFloor', []);
598
- }
599
-
600
- leaseState(): Promise<LeaseState | undefined> {
601
- return this.#call('leaseState', []);
602
- }
603
-
604
595
  /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
605
- upgrading(): Promise<boolean> {
606
- return this.#call('upgrading', []);
607
- }
608
-
609
- syncNeeded(): Promise<boolean> {
610
- return this.#call('syncNeeded', []);
611
- }
612
-
613
596
  pendingCommits(): Promise<OutboxCommit[]> {
614
597
  return this.#call('pendingCommits', []);
615
598
  }