@syncular/client 0.15.13 → 0.15.15

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.
@@ -5,6 +5,22 @@ export declare const FOLLOWER_TIMEOUT_CODE = "client.follower_timeout";
5
5
  export declare const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10000;
6
6
  /** Max follower calls queued across a handover before we fail loudly. */
7
7
  export declare const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
8
+ export type LeadershipState = {
9
+ readonly state: 'leader';
10
+ readonly clientId: string;
11
+ } | {
12
+ readonly state: 'follower';
13
+ readonly leaderClientId: string;
14
+ readonly epoch: number;
15
+ } | {
16
+ readonly state: 'waiting';
17
+ readonly reason: 'handover' | 'leader-announcement';
18
+ } | {
19
+ readonly state: 'blocked';
20
+ readonly reason: 'leader-unreachable';
21
+ readonly code: typeof FOLLOWER_TIMEOUT_CODE;
22
+ readonly retryable: true;
23
+ };
8
24
  interface HelloMessage {
9
25
  readonly t: 'hello';
10
26
  readonly fromId: string;
@@ -16,6 +32,10 @@ interface ByeMessage {
16
32
  readonly t: 'bye';
17
33
  readonly fromId: string;
18
34
  }
35
+ interface BoundMessage {
36
+ readonly t: 'bound';
37
+ readonly fromId: string;
38
+ }
19
39
  interface ReqMessage {
20
40
  readonly t: 'req';
21
41
  readonly epoch: number;
@@ -46,7 +66,7 @@ interface EventMessage {
46
66
  readonly epoch: number;
47
67
  readonly event: SyncWorkerEvent;
48
68
  }
49
- export type MultiTabMessage = HelloMessage | ByeMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
69
+ export type MultiTabMessage = HelloMessage | ByeMessage | BoundMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
50
70
  /**
51
71
  * The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
52
72
  * satisfies it. Injectable so bun tests can pair two instances by name.
@@ -80,6 +100,7 @@ export declare class LeaderBridge {
80
100
  epoch: number;
81
101
  clientId: string;
82
102
  invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
103
+ heartbeatMs?: number;
83
104
  });
84
105
  /** Re-announce leadership (on promotion and on a follower `hello`). */
85
106
  announce(): void;
@@ -101,12 +122,14 @@ export declare class FollowerLink {
101
122
  fromId: string;
102
123
  onEvent: (event: SyncWorkerEvent) => void;
103
124
  onLeaderChange: (clientId: string) => void;
125
+ onStateChange?: (state: LeadershipState) => void;
104
126
  callTimeoutMs?: number;
105
127
  queueLimit?: number;
106
128
  });
107
129
  get epoch(): number;
108
130
  get maxEpochSeen(): number;
109
131
  get leaderClientId(): string;
132
+ get leadershipState(): LeadershipState;
110
133
  /** Whether a leader is currently bound (an announce has been heard). */
111
134
  get bound(): boolean;
112
135
  /**
package/dist/multi-tab.js CHANGED
@@ -73,12 +73,18 @@ export class LeaderBridge {
73
73
  #clientId;
74
74
  #invoke;
75
75
  #onMessage;
76
+ #heartbeatMs;
77
+ #followers = new Set();
78
+ #heartbeat;
76
79
  #closed = false;
77
80
  constructor(options) {
78
81
  this.#channel = options.channel;
79
82
  this.#epoch = options.epoch;
80
83
  this.#clientId = options.clientId;
81
84
  this.#invoke = options.invoke;
85
+ this.#heartbeatMs =
86
+ options.heartbeatMs ??
87
+ Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
82
88
  this.#onMessage = (event) => this.#handle(event.data);
83
89
  this.#channel.addEventListener('message', this.#onMessage);
84
90
  this.announce();
@@ -104,9 +110,22 @@ export class LeaderBridge {
104
110
  return;
105
111
  if (message.t === 'hello') {
106
112
  // A follower joined (or is contesting) — tell it who leads.
113
+ this.#trackFollower(message.fromId);
107
114
  this.announce();
108
115
  return;
109
116
  }
117
+ if (message.t === 'bound') {
118
+ this.#trackFollower(message.fromId);
119
+ return;
120
+ }
121
+ if (message.t === 'bye') {
122
+ this.#followers.delete(message.fromId);
123
+ if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
124
+ clearInterval(this.#heartbeat);
125
+ this.#heartbeat = undefined;
126
+ }
127
+ return;
128
+ }
110
129
  if (message.t !== 'req')
111
130
  return;
112
131
  // Ignore requests stamped for a different (dead/older) leader; that
@@ -143,11 +162,21 @@ export class LeaderBridge {
143
162
  });
144
163
  });
145
164
  }
165
+ #trackFollower(fromId) {
166
+ this.#followers.add(fromId);
167
+ if (this.#heartbeat !== undefined)
168
+ return;
169
+ this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
170
+ }
146
171
  close() {
147
172
  if (this.#closed)
148
173
  return;
149
174
  this.#closed = true;
175
+ if (this.#heartbeat !== undefined)
176
+ clearInterval(this.#heartbeat);
177
+ this.#heartbeat = undefined;
150
178
  this.#channel.removeEventListener('message', this.#onMessage);
179
+ this.#channel.close();
151
180
  }
152
181
  }
153
182
  /**
@@ -162,6 +191,7 @@ export class FollowerLink {
162
191
  #fromId;
163
192
  #onEvent;
164
193
  #onLeaderChange;
194
+ #onStateChange;
165
195
  #callTimeoutMs;
166
196
  #queueLimit;
167
197
  #onMessage;
@@ -174,6 +204,12 @@ export class FollowerLink {
174
204
  #inFlight = new Map();
175
205
  #queue = [];
176
206
  #closed = false;
207
+ #state = {
208
+ state: 'waiting',
209
+ reason: 'leader-announcement',
210
+ };
211
+ #waitingTimer;
212
+ #blockedTimer;
177
213
  /** Resolvers waiting for the first `announce` to bind a leader. */
178
214
  #bindWaiters = [];
179
215
  constructor(options) {
@@ -181,6 +217,7 @@ export class FollowerLink {
181
217
  this.#fromId = options.fromId;
182
218
  this.#onEvent = options.onEvent;
183
219
  this.#onLeaderChange = options.onLeaderChange;
220
+ this.#onStateChange = options.onStateChange ?? (() => { });
184
221
  this.#callTimeoutMs =
185
222
  options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
186
223
  this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
@@ -188,6 +225,7 @@ export class FollowerLink {
188
225
  this.#channel.addEventListener('message', this.#onMessage);
189
226
  // Ask the current leader to announce itself.
190
227
  this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
228
+ this.#armUnboundDeadline();
191
229
  }
192
230
  get epoch() {
193
231
  return this.#epoch;
@@ -198,6 +236,9 @@ export class FollowerLink {
198
236
  get leaderClientId() {
199
237
  return this.#leaderClientId;
200
238
  }
239
+ get leadershipState() {
240
+ return this.#state;
241
+ }
201
242
  /** Whether a leader is currently bound (an announce has been heard). */
202
243
  get bound() {
203
244
  return this.#epoch >= 0;
@@ -221,7 +262,8 @@ export class FollowerLink {
221
262
  return new Promise((resolve, reject) => {
222
263
  const timer = setTimeout(() => {
223
264
  this.#dropBindWaiter(settle);
224
- reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout'));
265
+ this.#setBlocked();
266
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout', true));
225
267
  }, timeoutMs);
226
268
  const settle = () => {
227
269
  clearTimeout(timer);
@@ -248,6 +290,9 @@ export class FollowerLink {
248
290
  if (this.#closed) {
249
291
  return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
250
292
  }
293
+ if (this.#state.state === 'blocked') {
294
+ return Promise.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true));
295
+ }
251
296
  return new Promise((resolve, reject) => {
252
297
  const queued = {
253
298
  method,
@@ -259,12 +304,14 @@ export class FollowerLink {
259
304
  if (this.#epoch < 0) {
260
305
  // No leader bound yet — queue with a deadline so we never hang.
261
306
  if (this.#queue.length >= this.#queueLimit) {
262
- reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader'));
307
+ this.#setBlocked();
308
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader', true));
263
309
  return;
264
310
  }
265
311
  queued.timer = setTimeout(() => {
266
312
  this.#dropQueued(queued);
267
- reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout'));
313
+ this.#setBlocked();
314
+ reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout', true));
268
315
  }, this.#callTimeoutMs);
269
316
  this.#queue.push(queued);
270
317
  return;
@@ -279,7 +326,8 @@ export class FollowerLink {
279
326
  reject: queued.reject,
280
327
  timer: setTimeout(() => {
281
328
  this.#inFlight.delete(reqId);
282
- queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout'));
329
+ this.#setBlocked();
330
+ queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
283
331
  }, this.#callTimeoutMs),
284
332
  };
285
333
  this.#inFlight.set(reqId, inflight);
@@ -313,6 +361,15 @@ export class FollowerLink {
313
361
  this.#leaderClientId = message.clientId;
314
362
  if (changed)
315
363
  this.#onLeaderChange(message.clientId);
364
+ this.#setState({
365
+ state: 'follower',
366
+ leaderClientId: message.clientId,
367
+ epoch: message.epoch,
368
+ });
369
+ this.#armBoundDeadline();
370
+ if (changed) {
371
+ this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
372
+ }
316
373
  this.#resolveBindWaiters();
317
374
  this.#flushQueue();
318
375
  return;
@@ -366,6 +423,8 @@ export class FollowerLink {
366
423
  return;
367
424
  this.#epoch = -1;
368
425
  this.#leaderClientId = '';
426
+ this.#setState({ state: 'waiting', reason: 'handover' });
427
+ this.#armUnboundDeadline();
369
428
  this.#channel.postMessage({
370
429
  t: 'hello',
371
430
  fromId: this.#fromId,
@@ -376,6 +435,7 @@ export class FollowerLink {
376
435
  if (this.#closed)
377
436
  return;
378
437
  this.#closed = true;
438
+ this.#clearReachabilityTimers();
379
439
  this.#channel.removeEventListener('message', this.#onMessage);
380
440
  this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
381
441
  const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
@@ -396,4 +456,53 @@ export class FollowerLink {
396
456
  this.#resolveBindWaiters();
397
457
  this.#channel.close();
398
458
  }
459
+ #setState(state) {
460
+ const previous = this.#state;
461
+ if (previous.state === state.state &&
462
+ (state.state === 'blocked' ||
463
+ (state.state === 'waiting' &&
464
+ previous.state === 'waiting' &&
465
+ previous.reason === state.reason) ||
466
+ (state.state === 'follower' &&
467
+ previous.state === 'follower' &&
468
+ previous.epoch === state.epoch &&
469
+ previous.leaderClientId === state.leaderClientId))) {
470
+ return;
471
+ }
472
+ this.#state = state;
473
+ try {
474
+ this.#onStateChange(state);
475
+ }
476
+ catch {
477
+ // A status listener must never break cross-tab coordination.
478
+ }
479
+ }
480
+ #setBlocked() {
481
+ this.#clearReachabilityTimers();
482
+ this.#setState({
483
+ state: 'blocked',
484
+ reason: 'leader-unreachable',
485
+ code: FOLLOWER_TIMEOUT_CODE,
486
+ retryable: true,
487
+ });
488
+ }
489
+ #armUnboundDeadline() {
490
+ this.#clearReachabilityTimers();
491
+ this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
492
+ }
493
+ #armBoundDeadline() {
494
+ this.#clearReachabilityTimers();
495
+ this.#waitingTimer = setTimeout(() => {
496
+ this.#setState({ state: 'waiting', reason: 'leader-announcement' });
497
+ }, Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)));
498
+ this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
499
+ }
500
+ #clearReachabilityTimers() {
501
+ if (this.#waitingTimer !== undefined)
502
+ clearTimeout(this.#waitingTimer);
503
+ if (this.#blockedTimer !== undefined)
504
+ clearTimeout(this.#blockedTimer);
505
+ this.#waitingTimer = undefined;
506
+ this.#blockedTimer = undefined;
507
+ }
399
508
  }
@@ -1,6 +1,8 @@
1
+ import { type SyncAvailability } from './availability.js';
1
2
  import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
2
3
  import type { SqlValue } from './database.js';
3
4
  import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
5
+ import type { LeadershipState } from './multi-tab.js';
4
6
  import { type WindowBase } from './window.js';
5
7
  export interface QueryDependency {
6
8
  readonly table: string;
@@ -12,21 +14,26 @@ export interface ReactiveQuerySpec<Row> {
12
14
  readonly params?: readonly SqlValue[];
13
15
  readonly dependencies: readonly QueryDependency[];
14
16
  readonly coverage?: readonly WindowCoverage[];
17
+ readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
15
18
  readonly rowKey?: (row: Row) => readonly SqlValue[];
16
19
  readonly claimCoverage?: boolean;
17
20
  }
18
- export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
21
+ export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'blocked' | 'error';
19
22
  export interface LiveQueryResult<Row> {
20
23
  readonly rows: readonly Row[];
21
24
  readonly phase: LiveQueryPhase;
22
25
  readonly revision: bigint | undefined;
23
26
  readonly error: Error | undefined;
24
27
  readonly isRefreshing: boolean;
28
+ readonly availability: SyncAvailability;
25
29
  }
26
30
  export interface ReactiveQueryClient {
31
+ readonly currentSchemaVersion?: number;
27
32
  onChange(listener: ClientChangeListener): () => void;
28
33
  querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
29
34
  statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
35
+ leadershipSnapshot?(): LeadershipState | undefined;
36
+ onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
30
37
  readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
31
38
  readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
32
39
  commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
@@ -46,6 +53,7 @@ export interface WindowRetention {
46
53
  }
47
54
  export interface StatusStoreSnapshot {
48
55
  readonly status: SyncStatusSnapshot | undefined;
56
+ readonly leadership: LeadershipState | undefined;
49
57
  readonly error: Error | undefined;
50
58
  readonly isLoading: boolean;
51
59
  }
@@ -70,6 +78,7 @@ export declare class ReactiveClientStore {
70
78
  readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
71
79
  constructor(client: ReactiveQueryClient);
72
80
  query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
81
+ availabilitySnapshot(): SyncAvailability;
73
82
  /** Retain a composable window working set outside React. The returned
74
83
  * handle exposes registration completion and releases only this owner. */
75
84
  retainWindow(base: WindowBase, units: readonly string[]): WindowRetention;
@@ -1,3 +1,4 @@
1
+ import { classifySyncAvailability, } from './availability.js';
1
2
  import { windowBaseKey } from './window.js';
2
3
  function errorOf(value) {
3
4
  return value instanceof Error ? value : new Error(String(value));
@@ -62,6 +63,19 @@ function encodeCanonical(value, stack) {
62
63
  export function canonicalValue(value) {
63
64
  return encodeCanonical(value, new Set());
64
65
  }
66
+ const reactiveFunctionIds = new WeakMap();
67
+ let nextReactiveFunctionId = 1;
68
+ function reactiveFunctionId(fn) {
69
+ if (fn === undefined)
70
+ return undefined;
71
+ let id = reactiveFunctionIds.get(fn);
72
+ if (id === undefined) {
73
+ id = nextReactiveFunctionId;
74
+ nextReactiveFunctionId += 1;
75
+ reactiveFunctionIds.set(fn, id);
76
+ }
77
+ return id;
78
+ }
65
79
  function scheduleMicrotask(task) {
66
80
  if (typeof queueMicrotask === 'function')
67
81
  queueMicrotask(task);
@@ -175,6 +189,7 @@ class QueryEntry {
175
189
  revision: undefined,
176
190
  error: undefined,
177
191
  isRefreshing: false,
192
+ availability: { state: 'ready' },
178
193
  };
179
194
  #subscribers = 0;
180
195
  #scheduled = false;
@@ -182,6 +197,7 @@ class QueryEntry {
182
197
  #requested = false;
183
198
  #desiredRevision = 0n;
184
199
  #claimReady = Promise.resolve();
200
+ #offStatus;
185
201
  constructor(store, spec) {
186
202
  this.store = store;
187
203
  this.spec = spec;
@@ -191,6 +207,8 @@ class QueryEntry {
191
207
  this.#listeners.add(listener);
192
208
  this.#subscribers += 1;
193
209
  if (this.#subscribers === 1) {
210
+ this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
211
+ this.#onAvailabilityChange();
194
212
  if (this.spec.claimCoverage !== false) {
195
213
  const claims = [];
196
214
  for (const coverage of this.spec.coverage ?? []) {
@@ -204,8 +222,11 @@ class QueryEntry {
204
222
  if (!this.#listeners.delete(listener))
205
223
  return;
206
224
  this.#subscribers -= 1;
207
- if (this.#subscribers === 0)
225
+ if (this.#subscribers === 0) {
226
+ this.#offStatus?.();
227
+ this.#offStatus = undefined;
208
228
  this.store.releaseWindowClaims(this.#owner);
229
+ }
209
230
  };
210
231
  };
211
232
  refresh = () => this.#requestRead(true);
@@ -222,7 +243,9 @@ class QueryEntry {
222
243
  if (next.rows === this.#state.rows &&
223
244
  next.phase === this.#state.phase &&
224
245
  next.error === this.#state.error &&
225
- next.isRefreshing === this.#state.isRefreshing) {
246
+ next.isRefreshing === this.#state.isRefreshing &&
247
+ canonicalValue(next.availability) ===
248
+ canonicalValue(this.#state.availability)) {
226
249
  return;
227
250
  }
228
251
  this.#state = next;
@@ -244,6 +267,30 @@ class QueryEntry {
244
267
  void this.#readLoop();
245
268
  });
246
269
  }
270
+ #onAvailabilityChange() {
271
+ const availability = this.store.availabilitySnapshot();
272
+ if (availability.state === 'blocked') {
273
+ this.#publish({
274
+ ...this.#state,
275
+ phase: 'blocked',
276
+ availability,
277
+ isRefreshing: false,
278
+ });
279
+ return;
280
+ }
281
+ const wasBlocked = this.#state.phase === 'blocked';
282
+ this.#publish({
283
+ ...this.#state,
284
+ phase: wasBlocked
285
+ ? this.#state.rows.length > 0
286
+ ? 'partial'
287
+ : 'loading'
288
+ : this.#state.phase,
289
+ availability,
290
+ });
291
+ if (wasBlocked)
292
+ this.#requestRead();
293
+ }
247
294
  async #readLoop() {
248
295
  if (this.#running || this.#subscribers === 0)
249
296
  return;
@@ -251,6 +298,8 @@ class QueryEntry {
251
298
  try {
252
299
  do {
253
300
  this.#requested = false;
301
+ if (this.store.availabilitySnapshot().state === 'blocked')
302
+ break;
254
303
  await this.#claimReady;
255
304
  const snapshot = await this.store.client.querySnapshot({
256
305
  sql: this.spec.sql,
@@ -261,11 +310,24 @@ class QueryEntry {
261
310
  ? { coverage: this.spec.coverage }
262
311
  : {}),
263
312
  });
313
+ const availability = this.store.availabilitySnapshot();
314
+ if (availability.state === 'blocked') {
315
+ this.#publish({
316
+ ...this.#state,
317
+ phase: 'blocked',
318
+ availability,
319
+ isRefreshing: false,
320
+ });
321
+ break;
322
+ }
264
323
  if (snapshot.revision < this.#desiredRevision) {
265
324
  this.#requested = true;
266
325
  continue;
267
326
  }
268
- const rows = reconcileRows(this.#state.rows, snapshot.rows, this.spec.rowKey);
327
+ const mappedRows = this.spec.mapRow === undefined
328
+ ? snapshot.rows
329
+ : snapshot.rows.map(this.spec.mapRow);
330
+ const rows = reconcileRows(this.#state.rows, mappedRows, this.spec.rowKey);
269
331
  const phase = snapshot.coverage.complete
270
332
  ? 'ready'
271
333
  : rows.length > 0
@@ -277,6 +339,7 @@ class QueryEntry {
277
339
  revision: snapshot.revision,
278
340
  error: undefined,
279
341
  isRefreshing: false,
342
+ availability,
280
343
  });
281
344
  } while (this.#requested && this.#subscribers > 0);
282
345
  }
@@ -287,6 +350,7 @@ class QueryEntry {
287
350
  phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
288
351
  error: wrapped,
289
352
  isRefreshing: false,
353
+ availability: this.store.availabilitySnapshot(),
290
354
  });
291
355
  }
292
356
  finally {
@@ -383,21 +447,33 @@ export class ReactiveClientStore {
383
447
  #windows = new Map();
384
448
  #windowClaims = new Map();
385
449
  #offChange;
450
+ #offLeadership;
386
451
  status;
387
452
  conflicts;
388
453
  outcomes;
389
454
  constructor(client) {
390
455
  this.client = client;
391
- const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
456
+ const status = new ValueEntry({
457
+ status: undefined,
458
+ leadership: client.leadershipSnapshot?.(),
459
+ error: undefined,
460
+ isLoading: true,
461
+ }, async () => {
392
462
  try {
393
463
  return {
394
464
  status: await client.statusSnapshot(),
465
+ leadership: client.leadershipSnapshot?.(),
395
466
  error: undefined,
396
467
  isLoading: false,
397
468
  };
398
469
  }
399
470
  catch (error) {
400
- return { status: undefined, error: errorOf(error), isLoading: false };
471
+ return {
472
+ status: undefined,
473
+ leadership: client.leadershipSnapshot?.(),
474
+ error: errorOf(error),
475
+ isLoading: false,
476
+ };
401
477
  }
402
478
  });
403
479
  const conflicts = new ValueEntry({ conflicts: [], rejections: [], error: undefined, isLoading: true }, async () => {
@@ -453,6 +529,7 @@ export class ReactiveClientStore {
453
529
  baseKey: windowBaseKey(item.base),
454
530
  units: [...new Set(item.units)].sort(),
455
531
  }));
532
+ const mapRowId = reactiveFunctionId(spec.mapRow);
456
533
  const key = canonicalValue({
457
534
  id: spec.id,
458
535
  sql: spec.sql,
@@ -460,6 +537,7 @@ export class ReactiveClientStore {
460
537
  dependencies,
461
538
  coverage,
462
539
  claimCoverage: spec.claimCoverage !== false,
540
+ ...(mapRowId === undefined ? {} : { mapRow: mapRowId }),
463
541
  });
464
542
  let entry = this.#queries.get(key);
465
543
  if (entry === undefined) {
@@ -468,6 +546,20 @@ export class ReactiveClientStore {
468
546
  }
469
547
  return entry;
470
548
  }
549
+ availabilitySnapshot() {
550
+ const snapshot = this.status.getSnapshot();
551
+ if (snapshot.status === undefined) {
552
+ return snapshot.leadership?.state === 'blocked'
553
+ ? {
554
+ state: 'blocked',
555
+ reason: 'leader-unreachable',
556
+ currentSchemaVersion: this.client.currentSchemaVersion ?? 0,
557
+ retryable: true,
558
+ }
559
+ : { state: 'ready' };
560
+ }
561
+ return classifySyncAvailability(snapshot.status, snapshot.leadership);
562
+ }
471
563
  /** Retain a composable window working set outside React. The returned
472
564
  * handle exposes registration completion and releases only this owner. */
473
565
  retainWindow(base, units) {
@@ -571,6 +663,7 @@ export class ReactiveClientStore {
571
663
  if (batch.status !== undefined) {
572
664
  this.status.set({
573
665
  status: batch.status,
666
+ leadership: this.client.leadershipSnapshot?.(),
574
667
  error: undefined,
575
668
  isLoading: false,
576
669
  });
@@ -581,10 +674,19 @@ export class ReactiveClientStore {
581
674
  if (batch.outcomesChanged)
582
675
  this.outcomes.refresh();
583
676
  });
677
+ this.#offLeadership = this.client.onLeadershipChange?.((leadership) => {
678
+ const previous = this.status.getSnapshot();
679
+ this.status.set({
680
+ ...previous,
681
+ leadership,
682
+ });
683
+ });
584
684
  }
585
685
  dispose() {
586
686
  this.#offChange?.();
587
687
  this.#offChange = undefined;
688
+ this.#offLeadership?.();
689
+ this.#offLeadership = undefined;
588
690
  for (const group of this.#windowClaims.values()) {
589
691
  void Promise.resolve(this.client.setWindow(group.base, []));
590
692
  }
@@ -88,8 +88,11 @@ export function startSyncWorker(overrides = {}) {
88
88
  let backgroundDue = Number.POSITIVE_INFINITY;
89
89
  function runAutoSync() {
90
90
  autoSyncScheduled = false;
91
- if (closed || client === undefined)
91
+ if (closed ||
92
+ client === undefined ||
93
+ client.securityLifecycle === 'preflight') {
92
94
  return;
95
+ }
93
96
  const running = client;
94
97
  void serializedSync(() => running.syncUntilIdle())
95
98
  .then((summary) => {
@@ -106,7 +109,11 @@ export function startSyncWorker(overrides = {}) {
106
109
  });
107
110
  }
108
111
  function consumeSyncIntent(intent) {
109
- if (!autoSync || closed || client === undefined || intent.kind === 'none') {
112
+ if (!autoSync ||
113
+ closed ||
114
+ client === undefined ||
115
+ client.securityLifecycle === 'preflight' ||
116
+ intent.kind === 'none') {
110
117
  return;
111
118
  }
112
119
  if (intent.kind === 'background') {
@@ -220,6 +227,9 @@ export function startSyncWorker(overrides = {}) {
220
227
  ...(config.encryption !== undefined
221
228
  ? { encryption: encryptionConfigFromKeyring(config.encryption) }
222
229
  : {}),
230
+ ...(config.securityPreflight !== undefined
231
+ ? { securityPreflight: config.securityPreflight }
232
+ : {}),
223
233
  onSyncNeeded: (reason) => {
224
234
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
225
235
  consumeSyncIntent({ kind: 'interactive' });
@@ -257,6 +267,22 @@ export function startSyncWorker(overrides = {}) {
257
267
  return { clientId: started.clientId };
258
268
  }
259
269
  const api = {
270
+ securityLifecycle: () => requireClient().securityLifecycle,
271
+ beginSecurityPreflight: async () => {
272
+ if (backgroundTimer !== undefined)
273
+ clearTimeout(backgroundTimer);
274
+ backgroundTimer = undefined;
275
+ backgroundDue = Number.POSITIVE_INFINITY;
276
+ autoSyncScheduled = false;
277
+ await requireClient().beginSecurityPreflight();
278
+ },
279
+ activateSecurity: async (options = {}) => {
280
+ await requireClient().activateSecurity({
281
+ ...(options.encryption !== undefined
282
+ ? { encryption: encryptionConfigFromKeyring(options.encryption) }
283
+ : {}),
284
+ });
285
+ },
260
286
  subscribe: (input) => requireClient().subscribe(input),
261
287
  unsubscribe: (id) => requireClient().unsubscribe(id),
262
288
  setWindow: async (base, units) => {