@syncular/client 0.15.12 → 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/README.md CHANGED
@@ -73,7 +73,9 @@ const handle = await createSyncClientHandle({
73
73
  schema, database: { mode: 'persistent', name: 'app' }, endpoints,
74
74
  onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
75
75
  });
76
- // handle.role is 'leader' or 'follower'; the API is identical either way.
76
+ // Compatibility: handle.role is 'leader' or 'follower'.
77
+ // Detailed state: handle.leadership / handle.leadershipSnapshot().
78
+ handle.onLeadershipChange((state) => renderConnectionState(state));
77
79
  ```
78
80
 
79
81
  **Topology.** The tab that wins the Web Locks election is the **leader**:
@@ -105,6 +107,15 @@ flushed to the new leader on its announce; past the deadline they fail
105
107
  loudly with `client.follower_timeout` (never a silent hang), and an
106
108
  overflowing queue rejects rather than growing unbounded.
107
109
 
110
+ Leader announcements continue as heartbeats while followers are attached. If
111
+ a tab can acquire neither a response nor a new lock grant before the configured
112
+ `followerCallTimeoutMs`, `handle.leadership` becomes
113
+ `{ state: 'blocked', reason: 'leader-unreachable', code:
114
+ 'client.follower_timeout', retryable: true }`. Calls then reject immediately.
115
+ A later announcement rebinds the same handle; a granted Web Lock promotes it.
116
+ An unreachable `BroadcastChannel` is never treated as evidence that the lock
117
+ owner is stale, so it never authorizes a second worker or database owner.
118
+
108
119
  **Presence semantics — one device, one peer.** All tabs share the leader's
109
120
  single connection, so a device is exactly ONE presence peer collectively:
110
121
  identity is `(actorId, leaderClientId)`. A follower's `setPresence`
@@ -112,8 +123,61 @@ forwards to the leader's single publisher; there is no per-tab presence
112
123
  peer. This is the honest model — the wire only ever sees one connection per
113
124
  device.
114
125
 
126
+ The default is a **shared replica**: same-origin tabs must use the same
127
+ persistent database name, lock name, and derived channel name. For an embed,
128
+ preview, or history entry that is intentionally independent, derive the whole
129
+ ownership tuple from one stable identity:
130
+
131
+ ```ts
132
+ const preview = await createSyncClientHandle({
133
+ worker,
134
+ schema,
135
+ database: { mode: 'persistent', name: 'medical' },
136
+ endpoints,
137
+ replica: { mode: 'isolated', id: 'preview-42' },
138
+ });
139
+ ```
140
+
141
+ This derives a distinct database name and pool directory, Web Lock name, and
142
+ `BroadcastChannel` name together. `isolatedReplicaNames()` exposes the same
143
+ deterministic tuple for diagnostics and host integration. Replica IDs are
144
+ stable code-like values (`A-Z`, `a-z`, `0-9`, dot, underscore, dash).
145
+
146
+ During Vite development, retain the React client resource only while its
147
+ captured generated schema version matches. The
148
+ [schema-aware Vite guide](https://syncular.dev/guide-vite/) uses
149
+ `retainViteSyncClientResource` to close the old worker before constructing a
150
+ schema-bump replacement; hot-reloading query code alone does not migrate the
151
+ worker-owned database.
152
+
115
153
  Set `multiTab: false` to opt out. A losing tab then becomes an
116
- `isLeader === false` handle whose calls reject with `client.not_leader`.
154
+ `isLeader === false` handle whose calls reject with `client.not_leader`. This
155
+ does not solve a coordination-partition mismatch by itself: an independent
156
+ instance must also use an isolated database and lock identity. Changing only
157
+ the channel, lock, or database name is unsafe or ineffective.
158
+
159
+ ## React availability guard
160
+
161
+ The worker handle's schema and leadership snapshots feed the same public React
162
+ boundary as native clients. Guard the application once instead of parsing
163
+ errors or inspecting generated schema modules:
164
+
165
+ ```tsx
166
+ <SyncProvider
167
+ client={clientResource}
168
+ renderBoundary={(state, actions) => (
169
+ <SyncBlockedScreen state={state} onRetry={actions.retry} />
170
+ )}
171
+ >
172
+ <App />
173
+ </SyncProvider>
174
+ ```
175
+
176
+ The state is a discriminated union covering startup, migration,
177
+ `client-upgrade-required`, `server-behind`, `incompatible-schema`, and
178
+ `leader-unreachable`. Recovery changes the same handle/provider back to its
179
+ children; a blocked live query has `phase === 'blocked'`, never an indefinite
180
+ loading state.
117
181
 
118
182
  ## Durable commit outcomes
119
183
 
@@ -0,0 +1,17 @@
1
+ import type { SyncStatusSnapshot } from './invalidation.js';
2
+ import type { LeadershipState } from './multi-tab.js';
3
+ export type SyncAvailability = {
4
+ readonly state: 'ready';
5
+ } | {
6
+ readonly state: 'migrating';
7
+ readonly currentSchemaVersion: number;
8
+ } | {
9
+ readonly state: 'blocked';
10
+ readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable';
11
+ readonly currentSchemaVersion: number;
12
+ readonly requiredSchemaVersion?: number;
13
+ readonly latestServerSchemaVersion?: number;
14
+ readonly retryable: boolean;
15
+ };
16
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
17
+ export declare function classifySyncAvailability(status: SyncStatusSnapshot, leadership?: LeadershipState): SyncAvailability;
@@ -0,0 +1,48 @@
1
+ /** Classify schema and browser-ownership state without parsing diagnostics. */
2
+ export function classifySyncAvailability(status, leadership) {
3
+ const currentSchemaVersion = status.currentSchemaVersion;
4
+ if (leadership?.state === 'blocked') {
5
+ return {
6
+ state: 'blocked',
7
+ reason: 'leader-unreachable',
8
+ currentSchemaVersion,
9
+ retryable: true,
10
+ };
11
+ }
12
+ const required = status.schemaFloor?.requiredSchemaVersion;
13
+ const latest = status.schemaFloor?.latestSchemaVersion;
14
+ if (required !== undefined && required > currentSchemaVersion) {
15
+ return {
16
+ state: 'blocked',
17
+ reason: 'client-upgrade-required',
18
+ currentSchemaVersion,
19
+ requiredSchemaVersion: required,
20
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
21
+ retryable: false,
22
+ };
23
+ }
24
+ if (latest !== undefined && latest < currentSchemaVersion) {
25
+ return {
26
+ state: 'blocked',
27
+ reason: 'server-behind',
28
+ currentSchemaVersion,
29
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
30
+ latestServerSchemaVersion: latest,
31
+ retryable: false,
32
+ };
33
+ }
34
+ if (status.schemaFloor !== undefined) {
35
+ return {
36
+ state: 'blocked',
37
+ reason: 'incompatible-schema',
38
+ currentSchemaVersion,
39
+ ...(required !== undefined ? { requiredSchemaVersion: required } : {}),
40
+ ...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
41
+ retryable: false,
42
+ };
43
+ }
44
+ if (status.upgrading) {
45
+ return { state: 'migrating', currentSchemaVersion };
46
+ }
47
+ return { state: 'ready' };
48
+ }
package/dist/client.js CHANGED
@@ -181,9 +181,7 @@ export class SyncClient {
181
181
  this.#detectAndResetSchema();
182
182
  // A registration remains app intent only while its table exists in the
183
183
  // running schema. Removed-table registrations would poison every pull.
184
- this.#db.transaction(() => {
185
- pruneUnknownSubscriptions(this.#db, new Set(this.#schema.tables.keys()));
186
- });
184
+ const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), new Set(this.#schema.tables.keys()));
187
185
  this.#started = true;
188
186
  // A persisted active subscription needs one catch-up round on every open:
189
187
  // realtime only covers changes after the socket connects, and an
@@ -193,7 +191,7 @@ export class SyncClient {
193
191
  // an application-issued sync() call.
194
192
  const startupWork = this.#schemaFloor === undefined &&
195
193
  (listOutbox(this.#db).length > 0 ||
196
- loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
194
+ subscriptions.some((sub) => sub.status === 'active'));
197
195
  if (startupWork) {
198
196
  this.#needsPull = true;
199
197
  this.#config.onSyncNeeded?.('startup');
@@ -393,6 +391,7 @@ export class SyncClient {
393
391
  }
394
392
  #statusSnapshot() {
395
393
  return {
394
+ currentSchemaVersion: this.#config.schema.version,
396
395
  outbox: listOutbox(this.#db).length,
397
396
  upgrading: this.#upgrading,
398
397
  leaseState: this.#leaseState,
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply.js';
12
+ export * from './availability.js';
12
13
  export * from './blob.js';
13
14
  export * from './client.js';
14
15
  export * from './content-type.js';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * import no SQLite.
10
10
  */
11
11
  export * from './apply.js';
12
+ export * from './availability.js';
12
13
  export * from './blob.js';
13
14
  export * from './client.js';
14
15
  export * from './content-type.js';
@@ -20,6 +20,7 @@ export interface WindowChange {
20
20
  readonly units: ReadonlySet<string>;
21
21
  }
22
22
  export interface SyncStatusSnapshot {
23
+ readonly currentSchemaVersion: number;
23
24
  readonly outbox: number;
24
25
  readonly upgrading: boolean;
25
26
  readonly leaseState: LeaseState | undefined;
@@ -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;