@syncular/client 0.15.13 → 0.15.14

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/src/client.ts CHANGED
@@ -835,6 +835,7 @@ export class SyncClient {
835
835
 
836
836
  #statusSnapshot(): SyncStatusSnapshot {
837
837
  return {
838
+ currentSchemaVersion: this.#config.schema.version,
838
839
  outbox: listOutbox(this.#db).length,
839
840
  upgrading: this.#upgrading,
840
841
  leaseState: this.#leaseState,
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply';
12
+ export * from './availability';
12
13
  export * from './blob';
13
14
  export * from './client';
14
15
  export * from './content-type';
@@ -24,6 +24,7 @@ export interface WindowChange {
24
24
  }
25
25
 
26
26
  export interface SyncStatusSnapshot {
27
+ readonly currentSchemaVersion: number;
27
28
  readonly outbox: number;
28
29
  readonly upgrading: boolean;
29
30
  readonly leaseState: LeaseState | undefined;
package/src/multi-tab.ts CHANGED
@@ -46,6 +46,24 @@ export const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10_000;
46
46
  /** Max follower calls queued across a handover before we fail loudly. */
47
47
  export const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
48
48
 
49
+ export type LeadershipState =
50
+ | { readonly state: 'leader'; readonly clientId: string }
51
+ | {
52
+ readonly state: 'follower';
53
+ readonly leaderClientId: string;
54
+ readonly epoch: number;
55
+ }
56
+ | {
57
+ readonly state: 'waiting';
58
+ readonly reason: 'handover' | 'leader-announcement';
59
+ }
60
+ | {
61
+ readonly state: 'blocked';
62
+ readonly reason: 'leader-unreachable';
63
+ readonly code: typeof FOLLOWER_TIMEOUT_CODE;
64
+ readonly retryable: true;
65
+ };
66
+
49
67
  // ---------------------------------------------------------------------------
50
68
  // Wire messages
51
69
  // ---------------------------------------------------------------------------
@@ -63,6 +81,11 @@ interface ByeMessage {
63
81
  readonly fromId: string;
64
82
  }
65
83
 
84
+ interface BoundMessage {
85
+ readonly t: 'bound';
86
+ readonly fromId: string;
87
+ }
88
+
66
89
  interface ReqMessage {
67
90
  readonly t: 'req';
68
91
  readonly epoch: number;
@@ -96,6 +119,7 @@ interface EventMessage {
96
119
  export type MultiTabMessage =
97
120
  | HelloMessage
98
121
  | ByeMessage
122
+ | BoundMessage
99
123
  | ReqMessage
100
124
  | AnnounceMessage
101
125
  | ResMessage
@@ -157,6 +181,9 @@ export class LeaderBridge {
157
181
  args: readonly unknown[],
158
182
  ) => Promise<unknown>;
159
183
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
184
+ readonly #heartbeatMs: number;
185
+ readonly #followers = new Set<string>();
186
+ #heartbeat: ReturnType<typeof setInterval> | undefined;
160
187
  #closed = false;
161
188
 
162
189
  constructor(options: {
@@ -164,11 +191,15 @@ export class LeaderBridge {
164
191
  epoch: number;
165
192
  clientId: string;
166
193
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
194
+ heartbeatMs?: number;
167
195
  }) {
168
196
  this.#channel = options.channel;
169
197
  this.#epoch = options.epoch;
170
198
  this.#clientId = options.clientId;
171
199
  this.#invoke = options.invoke;
200
+ this.#heartbeatMs =
201
+ options.heartbeatMs ??
202
+ Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
172
203
  this.#onMessage = (event) => this.#handle(event.data);
173
204
  this.#channel.addEventListener('message', this.#onMessage);
174
205
  this.announce();
@@ -194,9 +225,22 @@ export class LeaderBridge {
194
225
  if (this.#closed) return;
195
226
  if (message.t === 'hello') {
196
227
  // A follower joined (or is contesting) — tell it who leads.
228
+ this.#trackFollower(message.fromId);
197
229
  this.announce();
198
230
  return;
199
231
  }
232
+ if (message.t === 'bound') {
233
+ this.#trackFollower(message.fromId);
234
+ return;
235
+ }
236
+ if (message.t === 'bye') {
237
+ this.#followers.delete(message.fromId);
238
+ if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
239
+ clearInterval(this.#heartbeat);
240
+ this.#heartbeat = undefined;
241
+ }
242
+ return;
243
+ }
200
244
  if (message.t !== 'req') return;
201
245
  // Ignore requests stamped for a different (dead/older) leader; that
202
246
  // follower will re-stamp once it sees our announce.
@@ -237,10 +281,19 @@ export class LeaderBridge {
237
281
  );
238
282
  }
239
283
 
284
+ #trackFollower(fromId: string): void {
285
+ this.#followers.add(fromId);
286
+ if (this.#heartbeat !== undefined) return;
287
+ this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
288
+ }
289
+
240
290
  close(): void {
241
291
  if (this.#closed) return;
242
292
  this.#closed = true;
293
+ if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
294
+ this.#heartbeat = undefined;
243
295
  this.#channel.removeEventListener('message', this.#onMessage);
296
+ this.#channel.close();
244
297
  }
245
298
  }
246
299
 
@@ -274,6 +327,7 @@ export class FollowerLink {
274
327
  readonly #fromId: string;
275
328
  readonly #onEvent: (event: SyncWorkerEvent) => void;
276
329
  readonly #onLeaderChange: (clientId: string) => void;
330
+ readonly #onStateChange: (state: LeadershipState) => void;
277
331
  readonly #callTimeoutMs: number;
278
332
  readonly #queueLimit: number;
279
333
  readonly #onMessage: (event: { data: MultiTabMessage }) => void;
@@ -287,6 +341,12 @@ export class FollowerLink {
287
341
  readonly #inFlight = new Map<number, InFlight>();
288
342
  #queue: QueuedCall[] = [];
289
343
  #closed = false;
344
+ #state: LeadershipState = {
345
+ state: 'waiting',
346
+ reason: 'leader-announcement',
347
+ };
348
+ #waitingTimer: ReturnType<typeof setTimeout> | undefined;
349
+ #blockedTimer: ReturnType<typeof setTimeout> | undefined;
290
350
  /** Resolvers waiting for the first `announce` to bind a leader. */
291
351
  #bindWaiters: Array<() => void> = [];
292
352
 
@@ -295,6 +355,7 @@ export class FollowerLink {
295
355
  fromId: string;
296
356
  onEvent: (event: SyncWorkerEvent) => void;
297
357
  onLeaderChange: (clientId: string) => void;
358
+ onStateChange?: (state: LeadershipState) => void;
298
359
  callTimeoutMs?: number;
299
360
  queueLimit?: number;
300
361
  }) {
@@ -302,6 +363,7 @@ export class FollowerLink {
302
363
  this.#fromId = options.fromId;
303
364
  this.#onEvent = options.onEvent;
304
365
  this.#onLeaderChange = options.onLeaderChange;
366
+ this.#onStateChange = options.onStateChange ?? (() => {});
305
367
  this.#callTimeoutMs =
306
368
  options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
307
369
  this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
@@ -309,6 +371,7 @@ export class FollowerLink {
309
371
  this.#channel.addEventListener('message', this.#onMessage);
310
372
  // Ask the current leader to announce itself.
311
373
  this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
374
+ this.#armUnboundDeadline();
312
375
  }
313
376
 
314
377
  get epoch(): number {
@@ -323,6 +386,10 @@ export class FollowerLink {
323
386
  return this.#leaderClientId;
324
387
  }
325
388
 
389
+ get leadershipState(): LeadershipState {
390
+ return this.#state;
391
+ }
392
+
326
393
  /** Whether a leader is currently bound (an announce has been heard). */
327
394
  get bound(): boolean {
328
395
  return this.#epoch >= 0;
@@ -348,10 +415,12 @@ export class FollowerLink {
348
415
  return new Promise((resolve, reject) => {
349
416
  const timer = setTimeout(() => {
350
417
  this.#dropBindWaiter(settle);
418
+ this.#setBlocked();
351
419
  reject(
352
420
  new ClientSyncError(
353
421
  FOLLOWER_TIMEOUT_CODE,
354
422
  'no leader announced within the follower bind timeout',
423
+ true,
355
424
  ),
356
425
  );
357
426
  }, timeoutMs);
@@ -382,6 +451,15 @@ export class FollowerLink {
382
451
  new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'),
383
452
  );
384
453
  }
454
+ if (this.#state.state === 'blocked') {
455
+ return Promise.reject(
456
+ new ClientSyncError(
457
+ FOLLOWER_TIMEOUT_CODE,
458
+ 'the follower cannot reach the tab that owns the database',
459
+ true,
460
+ ),
461
+ );
462
+ }
385
463
  return new Promise((resolve, reject) => {
386
464
  const queued: QueuedCall = {
387
465
  method,
@@ -393,20 +471,24 @@ export class FollowerLink {
393
471
  if (this.#epoch < 0) {
394
472
  // No leader bound yet — queue with a deadline so we never hang.
395
473
  if (this.#queue.length >= this.#queueLimit) {
474
+ this.#setBlocked();
396
475
  reject(
397
476
  new ClientSyncError(
398
477
  FOLLOWER_TIMEOUT_CODE,
399
478
  'follower call queue overflow while awaiting a leader',
479
+ true,
400
480
  ),
401
481
  );
402
482
  return;
403
483
  }
404
484
  queued.timer = setTimeout(() => {
405
485
  this.#dropQueued(queued);
486
+ this.#setBlocked();
406
487
  reject(
407
488
  new ClientSyncError(
408
489
  FOLLOWER_TIMEOUT_CODE,
409
490
  'no leader answered within the follower call timeout',
491
+ true,
410
492
  ),
411
493
  );
412
494
  }, this.#callTimeoutMs);
@@ -424,10 +506,12 @@ export class FollowerLink {
424
506
  reject: queued.reject,
425
507
  timer: setTimeout(() => {
426
508
  this.#inFlight.delete(reqId);
509
+ this.#setBlocked();
427
510
  queued.reject(
428
511
  new ClientSyncError(
429
512
  FOLLOWER_TIMEOUT_CODE,
430
513
  'the leader did not answer within the follower call timeout',
514
+ true,
431
515
  ),
432
516
  );
433
517
  }, this.#callTimeoutMs),
@@ -462,6 +546,15 @@ export class FollowerLink {
462
546
  this.#epoch = message.epoch;
463
547
  this.#leaderClientId = message.clientId;
464
548
  if (changed) this.#onLeaderChange(message.clientId);
549
+ this.#setState({
550
+ state: 'follower',
551
+ leaderClientId: message.clientId,
552
+ epoch: message.epoch,
553
+ });
554
+ this.#armBoundDeadline();
555
+ if (changed) {
556
+ this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
557
+ }
465
558
  this.#resolveBindWaiters();
466
559
  this.#flushQueue();
467
560
  return;
@@ -516,6 +609,8 @@ export class FollowerLink {
516
609
  if (this.#closed) return;
517
610
  this.#epoch = -1;
518
611
  this.#leaderClientId = '';
612
+ this.#setState({ state: 'waiting', reason: 'handover' });
613
+ this.#armUnboundDeadline();
519
614
  this.#channel.postMessage({
520
615
  t: 'hello',
521
616
  fromId: this.#fromId,
@@ -526,6 +621,7 @@ export class FollowerLink {
526
621
  close(): void {
527
622
  if (this.#closed) return;
528
623
  this.#closed = true;
624
+ this.#clearReachabilityTimers();
529
625
  this.#channel.removeEventListener('message', this.#onMessage);
530
626
  this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
531
627
  const closedError = new ClientSyncError(
@@ -547,4 +643,66 @@ export class FollowerLink {
547
643
  this.#resolveBindWaiters();
548
644
  this.#channel.close();
549
645
  }
646
+
647
+ #setState(state: LeadershipState): void {
648
+ const previous = this.#state;
649
+ if (
650
+ previous.state === state.state &&
651
+ (state.state === 'blocked' ||
652
+ (state.state === 'waiting' &&
653
+ previous.state === 'waiting' &&
654
+ previous.reason === state.reason) ||
655
+ (state.state === 'follower' &&
656
+ previous.state === 'follower' &&
657
+ previous.epoch === state.epoch &&
658
+ previous.leaderClientId === state.leaderClientId))
659
+ ) {
660
+ return;
661
+ }
662
+ this.#state = state;
663
+ try {
664
+ this.#onStateChange(state);
665
+ } catch {
666
+ // A status listener must never break cross-tab coordination.
667
+ }
668
+ }
669
+
670
+ #setBlocked(): void {
671
+ this.#clearReachabilityTimers();
672
+ this.#setState({
673
+ state: 'blocked',
674
+ reason: 'leader-unreachable',
675
+ code: FOLLOWER_TIMEOUT_CODE,
676
+ retryable: true,
677
+ });
678
+ }
679
+
680
+ #armUnboundDeadline(): void {
681
+ this.#clearReachabilityTimers();
682
+ this.#blockedTimer = setTimeout(
683
+ () => this.#setBlocked(),
684
+ this.#callTimeoutMs,
685
+ );
686
+ }
687
+
688
+ #armBoundDeadline(): void {
689
+ this.#clearReachabilityTimers();
690
+ this.#waitingTimer = setTimeout(
691
+ () => {
692
+ this.#setState({ state: 'waiting', reason: 'leader-announcement' });
693
+ },
694
+ Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)),
695
+ );
696
+ this.#blockedTimer = setTimeout(
697
+ () => this.#setBlocked(),
698
+ this.#callTimeoutMs,
699
+ );
700
+ }
701
+
702
+ #clearReachabilityTimers(): void {
703
+ if (this.#waitingTimer !== undefined) clearTimeout(this.#waitingTimer);
704
+ if (this.#blockedTimer !== undefined) clearTimeout(this.#blockedTimer);
705
+ this.#waitingTimer = undefined;
706
+ this.#blockedTimer = undefined;
707
+ }
550
708
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ classifySyncAvailability,
3
+ type SyncAvailability,
4
+ } from './availability';
1
5
  import type {
2
6
  CommitOutcome,
3
7
  QueryReadSpec,
@@ -11,6 +15,7 @@ import type {
11
15
  ClientChangeListener,
12
16
  SyncStatusSnapshot,
13
17
  } from './invalidation';
18
+ import type { LeadershipState } from './multi-tab';
14
19
  import { type WindowBase, windowBaseKey } from './window';
15
20
 
16
21
  export interface QueryDependency {
@@ -24,11 +29,17 @@ export interface ReactiveQuerySpec<Row> {
24
29
  readonly params?: readonly SqlValue[];
25
30
  readonly dependencies: readonly QueryDependency[];
26
31
  readonly coverage?: readonly WindowCoverage[];
32
+ readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
27
33
  readonly rowKey?: (row: Row) => readonly SqlValue[];
28
34
  readonly claimCoverage?: boolean;
29
35
  }
30
36
 
31
- export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
37
+ export type LiveQueryPhase =
38
+ | 'loading'
39
+ | 'partial'
40
+ | 'ready'
41
+ | 'blocked'
42
+ | 'error';
32
43
 
33
44
  export interface LiveQueryResult<Row> {
34
45
  readonly rows: readonly Row[];
@@ -36,14 +47,18 @@ export interface LiveQueryResult<Row> {
36
47
  readonly revision: bigint | undefined;
37
48
  readonly error: Error | undefined;
38
49
  readonly isRefreshing: boolean;
50
+ readonly availability: SyncAvailability;
39
51
  }
40
52
 
41
53
  export interface ReactiveQueryClient {
54
+ readonly currentSchemaVersion?: number;
42
55
  onChange(listener: ClientChangeListener): () => void;
43
56
  querySnapshot<Row = Record<string, SqlValue>>(
44
57
  spec: QueryReadSpec,
45
58
  ): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
46
59
  statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
60
+ leadershipSnapshot?(): LeadershipState | undefined;
61
+ onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
47
62
  readonly conflicts:
48
63
  | readonly unknown[]
49
64
  | (() => readonly unknown[] | Promise<readonly unknown[]>);
@@ -72,6 +87,7 @@ export interface WindowRetention {
72
87
 
73
88
  export interface StatusStoreSnapshot {
74
89
  readonly status: SyncStatusSnapshot | undefined;
90
+ readonly leadership: LeadershipState | undefined;
75
91
  readonly error: Error | undefined;
76
92
  readonly isLoading: boolean;
77
93
  }
@@ -162,6 +178,25 @@ export function canonicalValue(value: unknown): string {
162
178
  return encodeCanonical(value, new Set());
163
179
  }
164
180
 
181
+ const reactiveFunctionIds = new WeakMap<
182
+ (...args: never[]) => unknown,
183
+ number
184
+ >();
185
+ let nextReactiveFunctionId = 1;
186
+
187
+ function reactiveFunctionId(
188
+ fn: ((...args: never[]) => unknown) | undefined,
189
+ ): number | undefined {
190
+ if (fn === undefined) return undefined;
191
+ let id = reactiveFunctionIds.get(fn);
192
+ if (id === undefined) {
193
+ id = nextReactiveFunctionId;
194
+ nextReactiveFunctionId += 1;
195
+ reactiveFunctionIds.set(fn, id);
196
+ }
197
+ return id;
198
+ }
199
+
165
200
  function scheduleMicrotask(task: () => void): void {
166
201
  if (typeof queueMicrotask === 'function') queueMicrotask(task);
167
202
  else void Promise.resolve().then(task);
@@ -277,6 +312,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
277
312
  revision: undefined,
278
313
  error: undefined,
279
314
  isRefreshing: false,
315
+ availability: { state: 'ready' },
280
316
  };
281
317
  #subscribers = 0;
282
318
  #scheduled = false;
@@ -284,6 +320,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
284
320
  #requested = false;
285
321
  #desiredRevision = 0n;
286
322
  #claimReady: Promise<void> = Promise.resolve();
323
+ #offStatus: (() => void) | undefined;
287
324
 
288
325
  constructor(
289
326
  readonly store: ReactiveClientStore,
@@ -296,6 +333,10 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
296
333
  this.#listeners.add(listener);
297
334
  this.#subscribers += 1;
298
335
  if (this.#subscribers === 1) {
336
+ this.#offStatus = this.store.status.subscribe(() =>
337
+ this.#onAvailabilityChange(),
338
+ );
339
+ this.#onAvailabilityChange();
299
340
  if (this.spec.claimCoverage !== false) {
300
341
  const claims: Promise<void>[] = [];
301
342
  for (const coverage of this.spec.coverage ?? []) {
@@ -314,7 +355,11 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
314
355
  return () => {
315
356
  if (!this.#listeners.delete(listener)) return;
316
357
  this.#subscribers -= 1;
317
- if (this.#subscribers === 0) this.store.releaseWindowClaims(this.#owner);
358
+ if (this.#subscribers === 0) {
359
+ this.#offStatus?.();
360
+ this.#offStatus = undefined;
361
+ this.store.releaseWindowClaims(this.#owner);
362
+ }
318
363
  };
319
364
  };
320
365
 
@@ -333,7 +378,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
333
378
  next.rows === this.#state.rows &&
334
379
  next.phase === this.#state.phase &&
335
380
  next.error === this.#state.error &&
336
- next.isRefreshing === this.#state.isRefreshing
381
+ next.isRefreshing === this.#state.isRefreshing &&
382
+ canonicalValue(next.availability) ===
383
+ canonicalValue(this.#state.availability)
337
384
  ) {
338
385
  return;
339
386
  }
@@ -358,14 +405,41 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
358
405
  });
359
406
  }
360
407
 
408
+ #onAvailabilityChange(): void {
409
+ const availability = this.store.availabilitySnapshot();
410
+ if (availability.state === 'blocked') {
411
+ this.#publish({
412
+ ...this.#state,
413
+ phase: 'blocked',
414
+ availability,
415
+ isRefreshing: false,
416
+ });
417
+ return;
418
+ }
419
+ const wasBlocked = this.#state.phase === 'blocked';
420
+ this.#publish({
421
+ ...this.#state,
422
+ phase: wasBlocked
423
+ ? this.#state.rows.length > 0
424
+ ? 'partial'
425
+ : 'loading'
426
+ : this.#state.phase,
427
+ availability,
428
+ });
429
+ if (wasBlocked) this.#requestRead();
430
+ }
431
+
361
432
  async #readLoop(): Promise<void> {
362
433
  if (this.#running || this.#subscribers === 0) return;
363
434
  this.#running = true;
364
435
  try {
365
436
  do {
366
437
  this.#requested = false;
438
+ if (this.store.availabilitySnapshot().state === 'blocked') break;
367
439
  await this.#claimReady;
368
- const snapshot = await this.store.client.querySnapshot<Row>({
440
+ const snapshot = await this.store.client.querySnapshot<
441
+ Record<string, SqlValue>
442
+ >({
369
443
  sql: this.spec.sql,
370
444
  ...(this.spec.params !== undefined
371
445
  ? { params: this.spec.params }
@@ -374,13 +448,27 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
374
448
  ? { coverage: this.spec.coverage }
375
449
  : {}),
376
450
  });
451
+ const availability = this.store.availabilitySnapshot();
452
+ if (availability.state === 'blocked') {
453
+ this.#publish({
454
+ ...this.#state,
455
+ phase: 'blocked',
456
+ availability,
457
+ isRefreshing: false,
458
+ });
459
+ break;
460
+ }
377
461
  if (snapshot.revision < this.#desiredRevision) {
378
462
  this.#requested = true;
379
463
  continue;
380
464
  }
465
+ const mappedRows =
466
+ this.spec.mapRow === undefined
467
+ ? (snapshot.rows as readonly Row[])
468
+ : snapshot.rows.map(this.spec.mapRow);
381
469
  const rows = reconcileRows(
382
470
  this.#state.rows,
383
- snapshot.rows,
471
+ mappedRows,
384
472
  this.spec.rowKey,
385
473
  );
386
474
  const phase: LiveQueryPhase = snapshot.coverage.complete
@@ -394,6 +482,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
394
482
  revision: snapshot.revision,
395
483
  error: undefined,
396
484
  isRefreshing: false,
485
+ availability,
397
486
  });
398
487
  } while (this.#requested && this.#subscribers > 0);
399
488
  } catch (error) {
@@ -403,6 +492,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
403
492
  phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
404
493
  error: wrapped,
405
494
  isRefreshing: false,
495
+ availability: this.store.availabilitySnapshot(),
406
496
  });
407
497
  } finally {
408
498
  this.#running = false;
@@ -505,22 +595,34 @@ export class ReactiveClientStore {
505
595
  readonly #windows = new Map<string, WindowEntry>();
506
596
  readonly #windowClaims = new Map<string, WindowClaimGroup>();
507
597
  #offChange: (() => void) | undefined;
598
+ #offLeadership: (() => void) | undefined;
508
599
  readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
509
600
  readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
510
601
  readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
511
602
 
512
603
  constructor(readonly client: ReactiveQueryClient) {
513
604
  const status = new ValueEntry<StatusStoreSnapshot>(
514
- { status: undefined, error: undefined, isLoading: true },
605
+ {
606
+ status: undefined,
607
+ leadership: client.leadershipSnapshot?.(),
608
+ error: undefined,
609
+ isLoading: true,
610
+ },
515
611
  async () => {
516
612
  try {
517
613
  return {
518
614
  status: await client.statusSnapshot(),
615
+ leadership: client.leadershipSnapshot?.(),
519
616
  error: undefined,
520
617
  isLoading: false,
521
618
  };
522
619
  } catch (error) {
523
- return { status: undefined, error: errorOf(error), isLoading: false };
620
+ return {
621
+ status: undefined,
622
+ leadership: client.leadershipSnapshot?.(),
623
+ error: errorOf(error),
624
+ isLoading: false,
625
+ };
524
626
  }
525
627
  },
526
628
  );
@@ -584,6 +686,9 @@ export class ReactiveClientStore {
584
686
  baseKey: windowBaseKey(item.base),
585
687
  units: [...new Set(item.units)].sort(),
586
688
  }));
689
+ const mapRowId = reactiveFunctionId(
690
+ spec.mapRow as ((...args: never[]) => unknown) | undefined,
691
+ );
587
692
  const key = canonicalValue({
588
693
  id: spec.id,
589
694
  sql: spec.sql,
@@ -591,6 +696,7 @@ export class ReactiveClientStore {
591
696
  dependencies,
592
697
  coverage,
593
698
  claimCoverage: spec.claimCoverage !== false,
699
+ ...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
594
700
  });
595
701
  let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
596
702
  if (entry === undefined) {
@@ -600,6 +706,21 @@ export class ReactiveClientStore {
600
706
  return entry;
601
707
  }
602
708
 
709
+ availabilitySnapshot(): SyncAvailability {
710
+ const snapshot = this.status.getSnapshot();
711
+ if (snapshot.status === undefined) {
712
+ return snapshot.leadership?.state === 'blocked'
713
+ ? {
714
+ state: 'blocked',
715
+ reason: 'leader-unreachable',
716
+ currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
717
+ retryable: true,
718
+ }
719
+ : { state: 'ready' };
720
+ }
721
+ return classifySyncAvailability(snapshot.status, snapshot.leadership);
722
+ }
723
+
603
724
  /** Retain a composable window working set outside React. The returned
604
725
  * handle exposes registration completion and releases only this owner. */
605
726
  retainWindow(base: WindowBase, units: readonly string[]): WindowRetention {
@@ -701,6 +822,7 @@ export class ReactiveClientStore {
701
822
  if (batch.status !== undefined) {
702
823
  (this.status as ValueEntry<StatusStoreSnapshot>).set({
703
824
  status: batch.status,
825
+ leadership: this.client.leadershipSnapshot?.(),
704
826
  error: undefined,
705
827
  isLoading: false,
706
828
  });
@@ -710,11 +832,20 @@ export class ReactiveClientStore {
710
832
  }
711
833
  if (batch.outcomesChanged) this.outcomes.refresh();
712
834
  });
835
+ this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
836
+ const previous = this.status.getSnapshot();
837
+ (this.status as ValueEntry<StatusStoreSnapshot>).set({
838
+ ...previous,
839
+ leadership,
840
+ });
841
+ });
713
842
  }
714
843
 
715
844
  dispose(): void {
716
845
  this.#offChange?.();
717
846
  this.#offChange = undefined;
847
+ this.#offLeadership?.();
848
+ this.#offLeadership = undefined;
718
849
  for (const group of this.#windowClaims.values()) {
719
850
  void Promise.resolve(this.client.setWindow(group.base, []));
720
851
  }