@syncular/client 0.15.47 → 0.16.1

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 {
package/src/remote.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  encodeRemoteOperationRequest,
11
11
  encodeRemoteOperationRealtimeMessage,
12
12
  encodeRow,
13
- PROTOCOL_WIRE_VERSION,
14
13
  type PushOperation,
15
14
  type PushOperationResult,
16
15
  type PushResultDetailsFrame,
@@ -42,6 +41,8 @@ export interface SyncRemoteClientConfig {
42
41
  readonly operations?: RemoteOperationTransport;
43
42
  readonly operationRealtime?: RemoteOperationRealtimeConnector;
44
43
  readonly encryption?: EncryptionConfig;
44
+ /** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
45
+ readonly logEpoch?: string;
45
46
  }
46
47
 
47
48
  export interface RemoteCommitInput {
@@ -203,11 +204,15 @@ export class SyncRemoteClient {
203
204
  }
204
205
  >();
205
206
  readonly #encryption: EncryptionConfig | undefined;
207
+ readonly #logEpoch: string | undefined;
206
208
 
207
209
  constructor(config: SyncRemoteClientConfig) {
208
210
  if (config.clientId.length === 0) {
209
211
  throw invalid('SyncRemoteClient clientId must be non-empty');
210
212
  }
213
+ if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
214
+ throw invalid('SyncRemoteClient logEpoch must be non-empty');
215
+ }
211
216
  this.#schema =
212
217
  config.schema === undefined
213
218
  ? undefined
@@ -217,6 +222,7 @@ export class SyncRemoteClient {
217
222
  this.#operations = config.operations;
218
223
  this.#operationRealtime = config.operationRealtime;
219
224
  this.#encryption = config.encryption;
225
+ this.#logEpoch = config.logEpoch;
220
226
  }
221
227
 
222
228
  async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
@@ -277,13 +283,16 @@ export class SyncRemoteClient {
277
283
  return {
278
284
  requestId: input.requestId,
279
285
  bytes: encodeMessage({
280
- wireVersion: PROTOCOL_WIRE_VERSION,
286
+ wireVersion: this.#logEpoch === undefined ? 1 : 2,
281
287
  msgKind: 'request',
282
288
  frames: [
283
289
  {
284
290
  type: 'REQ_HEADER',
285
291
  clientId: this.#clientId,
286
292
  schemaVersion: schema.version,
293
+ ...(this.#logEpoch !== undefined
294
+ ? { logEpoch: this.#logEpoch }
295
+ : {}),
287
296
  },
288
297
  {
289
298
  type: 'PUSH_COMMIT',