@syncular/client 0.15.36 → 0.15.37

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
@@ -54,14 +54,48 @@ The handle exposes the same logical API as `SyncClient` (subscribe /
54
54
  mutate / sync / query / conflicts / …), every method a promise. It
55
55
  acquires the Web Locks leader lock *before* spawning the worker — one
56
56
  core per origin. Wake-ups are handled inside the worker (`autoSync`,
57
- SPEC §8.4: the sync-needed signal is host-driven and the worker IS the
58
- host); the main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
57
+ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
58
+ resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
59
59
  events for rendering.
60
60
 
61
61
  **Ephemeral in-memory mode is EXPLICIT.** `openWasmDatabase()` returns an
62
62
  in-memory sqlite-wasm database for tests, demos and SSR. Nothing
63
63
  persists, on purpose, and that is the only main-thread mode.
64
64
 
65
+ ## Supported realtime lifecycle
66
+
67
+ Register subscriptions, then install the cross-host supervisor:
68
+
69
+ ```ts
70
+ import {
71
+ browserConnectivitySignal,
72
+ documentLifecycleSignal,
73
+ installRealtimeSupervisor,
74
+ } from '@syncular/client';
75
+
76
+ await handle.subscribe({ id: 'todos', table: 'todos', scopes });
77
+ installRealtimeSupervisor(handle, {
78
+ connectivity: browserConnectivitySignal(),
79
+ lifecycle: documentLifecycleSignal(),
80
+ // Encrypted/locked apps also pass their active/preflight signal.
81
+ protection,
82
+ });
83
+ ```
84
+
85
+ It owns one connection attempt, retries initial failure and later socket loss
86
+ with bounded exponential jitter, suspends while offline/background/protected,
87
+ runs `syncUntilIdle()` before reporting `connected`, and cancels before
88
+ `close()`. `realtimeSupervisorSnapshot()` and
89
+ `subscribeRealtimeSupervisor()` expose only `phase`, `attempt`, and the bounded
90
+ library delay for UI/diagnostics—never raw transport prose or identities.
91
+
92
+ The lower-level `connectRealtime()` is idempotent while connected and
93
+ single-flight while connecting; disconnect invalidates an in-flight result so
94
+ it cannot install a stale socket. It remains available for custom host loops.
95
+ HTTP rounds still work with no socket, but do not imply continuous convergence:
96
+ a host trigger must actually run them. See the complete
97
+ [realtime lifecycle guide](https://syncular.dev/concepts-realtime/).
98
+
65
99
  ## Multi-tab followers (TODO 3.2, REVISE B3)
66
100
 
67
101
  By default, every tab of the same origin shares ONE core:
package/dist/client.js CHANGED
@@ -97,6 +97,8 @@ export class SyncClient {
97
97
  #conflicts = [];
98
98
  #rejections = [];
99
99
  #socket;
100
+ #realtimeConnectPromise;
101
+ #realtimeGeneration = 0;
100
102
  #pendingRound;
101
103
  #needsPull = false;
102
104
  #syncing = false;
@@ -315,8 +317,7 @@ export class SyncClient {
315
317
  async close() {
316
318
  this.#devtoolsUnregister?.();
317
319
  this.#devtoolsUnregister = undefined;
318
- this.#socket?.close();
319
- this.#socket = undefined;
320
+ this.disconnectRealtime();
320
321
  this.#abortPendingRound('client closed mid-round');
321
322
  await this.#lease?.release();
322
323
  this.#lease = undefined;
@@ -1939,31 +1940,59 @@ export class SyncClient {
1939
1940
  }
1940
1941
  // -- realtime (§8 client side) ----------------------------------------------
1941
1942
  connectRealtime() {
1942
- return this.#runProtectedAsync(() => this.#connectRealtime());
1943
+ this.#requireActive();
1944
+ if (this.#socket !== undefined)
1945
+ return Promise.resolve();
1946
+ if (this.#realtimeConnectPromise !== undefined) {
1947
+ return this.#realtimeConnectPromise;
1948
+ }
1949
+ const generation = this.#realtimeGeneration;
1950
+ const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
1951
+ this.#realtimeConnectPromise = task;
1952
+ void task.then(() => {
1953
+ if (this.#realtimeConnectPromise === task) {
1954
+ this.#realtimeConnectPromise = undefined;
1955
+ }
1956
+ }, () => {
1957
+ if (this.#realtimeConnectPromise === task) {
1958
+ this.#realtimeConnectPromise = undefined;
1959
+ }
1960
+ });
1961
+ return task;
1943
1962
  }
1944
- async #connectRealtime() {
1963
+ async #connectRealtime(generation) {
1945
1964
  const connector = this.#config.realtime;
1946
1965
  if (connector === undefined) {
1947
1966
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
1948
1967
  }
1968
+ let openedSocket;
1949
1969
  const socket = await connector({
1950
1970
  onText: (text) => this.#handleRealtimeText(text),
1951
1971
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1952
1972
  onClose: () => {
1973
+ if (openedSocket === undefined || this.#socket !== openedSocket)
1974
+ return;
1953
1975
  this.#socket = undefined;
1954
1976
  this.#presence.clear(); // §8.6.1: presence is per-connection
1955
1977
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1956
1978
  this.#emitDiagnostics();
1957
1979
  },
1958
1980
  });
1981
+ openedSocket = socket;
1959
1982
  if (this.#securityLifecycle === 'preflight') {
1960
1983
  socket.close();
1961
1984
  throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1962
1985
  }
1986
+ if (generation !== this.#realtimeGeneration || !this.#started) {
1987
+ socket.close();
1988
+ throw new ClientSyncError('client.realtime_cancelled', 'realtime connection was cancelled before activation');
1989
+ }
1963
1990
  this.#socket = socket;
1964
1991
  this.#emitDiagnostics();
1965
1992
  }
1966
1993
  disconnectRealtime() {
1994
+ this.#realtimeGeneration += 1;
1995
+ this.#realtimeConnectPromise = undefined;
1967
1996
  this.#socket?.close();
1968
1997
  this.#socket = undefined;
1969
1998
  this.#presence.clear(); // §8.6.1: presence is per-connection
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export * from './outbox.js';
29
29
  export * from './outcomes.js';
30
30
  export * from './query-guard.js';
31
31
  export * from './reactive-store.js';
32
+ export * from './realtime-supervisor.js';
32
33
  export * from './schema.js';
33
34
  export * from './sql-tag.js';
34
35
  export * from './state.js';
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export * from './outbox.js';
29
29
  export * from './outcomes.js';
30
30
  export * from './query-guard.js';
31
31
  export * from './reactive-store.js';
32
+ export * from './realtime-supervisor.js';
32
33
  export * from './schema.js';
33
34
  export * from './sql-tag.js';
34
35
  export * from './state.js';
@@ -0,0 +1,78 @@
1
+ import type { SecurityLifecycle } from './client.js';
2
+ import type { ClientDiagnosticsConnectivity, ClientDiagnosticsListener, ClientDiagnosticsSnapshot } from './diagnostics.js';
3
+ type CancelTimer = () => void;
4
+ export interface RealtimeSupervisorClient {
5
+ connectRealtime(): Promise<void>;
6
+ disconnectRealtime(): void | Promise<void>;
7
+ syncUntilIdle(maxRounds?: number): unknown | Promise<unknown>;
8
+ diagnosticsSnapshot(): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
9
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
10
+ close(): void | Promise<void>;
11
+ }
12
+ export interface RealtimeSupervisorSignal<State> {
13
+ current(): State;
14
+ subscribe(listener: (state: State) => void): () => void;
15
+ }
16
+ export type RealtimeSupervisorLifecycleState = 'active' | 'background' | 'unknown';
17
+ export type RealtimeSupervisorProtectionState = SecurityLifecycle | 'unknown';
18
+ type RealtimeSuspendedPhase = 'offline' | 'background' | 'protected';
19
+ export type RealtimeSupervisorPhase = 'idle' | 'connecting' | 'connected' | 'retrying' | RealtimeSuspendedPhase | 'unsupported' | 'stopped';
20
+ export interface RealtimeSupervisorSnapshot {
21
+ readonly phase: RealtimeSupervisorPhase;
22
+ /** One-based for retries; zero for the initial connection. */
23
+ readonly attempt: number;
24
+ /** Bounded host-policy delay, never server or transport prose. */
25
+ readonly retryDelayMs?: number;
26
+ }
27
+ export interface RealtimeSupervisorOptions {
28
+ /** Host online/offline evidence. Unknown remains connectable and observable. */
29
+ readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
30
+ /** Browser/native foreground evidence. Background always suspends the socket. */
31
+ readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
32
+ /** Publish preflight before draining keys so reconnect stops in the same turn. */
33
+ readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
34
+ /** Deterministic test/host timer seam. */
35
+ readonly schedule?: (callback: () => void, delayMs: number) => CancelTimer;
36
+ readonly random?: () => number;
37
+ readonly initialDelayMs?: number;
38
+ readonly maximumDelayMs?: number;
39
+ }
40
+ export interface RealtimeSupervisorEventTarget {
41
+ addEventListener(type: string, listener: () => void): void;
42
+ removeEventListener(type: string, listener: () => void): void;
43
+ }
44
+ export interface BrowserConnectivitySignalOptions {
45
+ readonly events?: RealtimeSupervisorEventTarget;
46
+ readonly network?: {
47
+ readonly onLine?: boolean;
48
+ };
49
+ }
50
+ export interface DocumentLifecycleSignalOptions {
51
+ readonly events?: RealtimeSupervisorEventTarget;
52
+ readonly document?: {
53
+ readonly visibilityState?: string;
54
+ };
55
+ }
56
+ /** Browser/Tauri-webview connectivity evidence without importing DOM types. */
57
+ export declare function browserConnectivitySignal(options?: BrowserConnectivitySignalOptions): RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
58
+ /** Browser/Tauri-webview visibility evidence without importing DOM types. */
59
+ export declare function documentLifecycleSignal(options?: DocumentLifecycleSignalOptions): RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
60
+ /**
61
+ * Supported host policy for Syncular's explicit realtime transport. It owns
62
+ * exactly one connect attempt, runs an explicit catch-up round before claiming
63
+ * connected, retries transient loss with bounded exponential jitter, and
64
+ * suspends across offline, background, or protected-preflight state.
65
+ */
66
+ export declare class RealtimeSupervisor {
67
+ #private;
68
+ constructor(client: RealtimeSupervisorClient, options?: RealtimeSupervisorOptions);
69
+ snapshot(): RealtimeSupervisorSnapshot;
70
+ subscribe(listener: () => void): () => void;
71
+ start(): void;
72
+ stop(): void;
73
+ }
74
+ /** Install one supervisor and make client disposal cancel it before close. */
75
+ export declare function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(client: T, options?: RealtimeSupervisorOptions): T;
76
+ export declare function realtimeSupervisorSnapshot(client: object): RealtimeSupervisorSnapshot;
77
+ export declare function subscribeRealtimeSupervisor(client: object, listener: () => void): () => void;
78
+ export {};
@@ -0,0 +1,452 @@
1
+ const REALTIME_SUPERVISOR_KEY = Symbol.for('syncular.realtime-supervisor.v1');
2
+ const DEFAULT_INITIAL_DELAY_MS = 1_000;
3
+ const DEFAULT_MAXIMUM_DELAY_MS = 30_000;
4
+ const MAXIMUM_CONFIGURED_DELAY_MS = 300_000;
5
+ const UNSUPPORTED_SNAPSHOT = Object.freeze({
6
+ phase: 'unsupported',
7
+ attempt: 0,
8
+ });
9
+ function attachment(client) {
10
+ const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY);
11
+ return candidate?.version === 1 && candidate.supervisor
12
+ ? candidate
13
+ : undefined;
14
+ }
15
+ function scheduleTimer(callback, delayMs) {
16
+ const timer = globalThis.setTimeout(callback, delayMs);
17
+ return () => globalThis.clearTimeout(timer);
18
+ }
19
+ function boundedDelay(value, fallback) {
20
+ if (value === undefined)
21
+ return fallback;
22
+ if (!Number.isSafeInteger(value) || value < 1) {
23
+ throw new TypeError('realtime supervisor delays must be positive safe integers');
24
+ }
25
+ return Math.min(value, MAXIMUM_CONFIGURED_DELAY_MS);
26
+ }
27
+ function eventTarget(value) {
28
+ const candidate = value;
29
+ return typeof candidate?.addEventListener === 'function' &&
30
+ typeof candidate.removeEventListener === 'function'
31
+ ? candidate
32
+ : undefined;
33
+ }
34
+ /** Browser/Tauri-webview connectivity evidence without importing DOM types. */
35
+ export function browserConnectivitySignal(options = {}) {
36
+ const root = globalThis;
37
+ const events = options.events ?? eventTarget(globalThis);
38
+ const network = options.network ?? root.navigator;
39
+ const current = () => {
40
+ if (network?.onLine === true)
41
+ return 'online';
42
+ if (network?.onLine === false)
43
+ return 'offline';
44
+ return 'unknown';
45
+ };
46
+ return {
47
+ current,
48
+ subscribe(listener) {
49
+ if (events === undefined)
50
+ return () => undefined;
51
+ const notify = () => listener(current());
52
+ events.addEventListener('online', notify);
53
+ events.addEventListener('offline', notify);
54
+ return () => {
55
+ events.removeEventListener('online', notify);
56
+ events.removeEventListener('offline', notify);
57
+ };
58
+ },
59
+ };
60
+ }
61
+ /** Browser/Tauri-webview visibility evidence without importing DOM types. */
62
+ export function documentLifecycleSignal(options = {}) {
63
+ const root = globalThis;
64
+ const document = options.document ?? root.document;
65
+ const events = options.events ?? eventTarget(document);
66
+ const classify = () => {
67
+ if (document?.visibilityState === 'visible')
68
+ return 'active';
69
+ if (document?.visibilityState === 'hidden')
70
+ return 'background';
71
+ return 'unknown';
72
+ };
73
+ let state = classify();
74
+ return {
75
+ current: () => state,
76
+ subscribe(listener) {
77
+ if (events === undefined)
78
+ return () => undefined;
79
+ const visibility = () => {
80
+ state = classify();
81
+ listener(state);
82
+ };
83
+ const background = () => {
84
+ state = 'background';
85
+ listener(state);
86
+ };
87
+ const active = () => {
88
+ state = classify() === 'background' ? 'background' : 'active';
89
+ listener(state);
90
+ };
91
+ events.addEventListener('visibilitychange', visibility);
92
+ events.addEventListener('pagehide', background);
93
+ events.addEventListener('pageshow', active);
94
+ return () => {
95
+ events.removeEventListener('visibilitychange', visibility);
96
+ events.removeEventListener('pagehide', background);
97
+ events.removeEventListener('pageshow', active);
98
+ };
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * Supported host policy for Syncular's explicit realtime transport. It owns
104
+ * exactly one connect attempt, runs an explicit catch-up round before claiming
105
+ * connected, retries transient loss with bounded exponential jitter, and
106
+ * suspends across offline, background, or protected-preflight state.
107
+ */
108
+ export class RealtimeSupervisor {
109
+ #client;
110
+ #connectivity;
111
+ #lifecycle;
112
+ #protection;
113
+ #scheduleTimer;
114
+ #random;
115
+ #initialDelayMs;
116
+ #maximumDelayMs;
117
+ #listeners = new Set();
118
+ #started = false;
119
+ #initialized = false;
120
+ #stopped = false;
121
+ #connected = false;
122
+ #transportConnected = false;
123
+ #connecting = false;
124
+ #realtimeSupported = true;
125
+ #diagnosticConnectivity = 'unknown';
126
+ #diagnosticSecurity = 'unknown';
127
+ #attempt = 0;
128
+ #generation = 0;
129
+ #snapshot = { phase: 'idle', attempt: 0 };
130
+ #cancelRetry;
131
+ #unsubscribeDiagnostics;
132
+ #unsubscribeConnectivity;
133
+ #unsubscribeLifecycle;
134
+ #unsubscribeProtection;
135
+ constructor(client, options = {}) {
136
+ this.#client = client;
137
+ this.#connectivity = options.connectivity;
138
+ this.#lifecycle = options.lifecycle;
139
+ this.#protection = options.protection;
140
+ this.#scheduleTimer = options.schedule ?? scheduleTimer;
141
+ this.#random = options.random ?? Math.random;
142
+ this.#initialDelayMs = boundedDelay(options.initialDelayMs, DEFAULT_INITIAL_DELAY_MS);
143
+ this.#maximumDelayMs = Math.max(this.#initialDelayMs, boundedDelay(options.maximumDelayMs, DEFAULT_MAXIMUM_DELAY_MS));
144
+ }
145
+ snapshot() {
146
+ return this.#snapshot;
147
+ }
148
+ subscribe(listener) {
149
+ this.#listeners.add(listener);
150
+ return () => this.#listeners.delete(listener);
151
+ }
152
+ start() {
153
+ if (this.#started || this.#stopped)
154
+ return;
155
+ this.#started = true;
156
+ this.#unsubscribeDiagnostics = this.#client.onDiagnostics((snapshot) => this.#observeDiagnostics(snapshot));
157
+ this.#unsubscribeConnectivity = this.#connectivity?.subscribe(() => this.#reconcileHostState());
158
+ this.#unsubscribeLifecycle = this.#lifecycle?.subscribe(() => this.#reconcileHostState());
159
+ this.#unsubscribeProtection = this.#protection?.subscribe(() => this.#reconcileHostState());
160
+ this.#reconcileHostState();
161
+ const generation = this.#generation;
162
+ void this.#initialize(generation);
163
+ }
164
+ stop() {
165
+ if (this.#stopped)
166
+ return;
167
+ this.#stopped = true;
168
+ this.#generation += 1;
169
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
170
+ this.#connected = false;
171
+ this.#transportConnected = false;
172
+ this.#connecting = false;
173
+ this.#attempt = 0;
174
+ this.#clearRetry();
175
+ this.#unsubscribeDiagnostics?.();
176
+ this.#unsubscribeConnectivity?.();
177
+ this.#unsubscribeLifecycle?.();
178
+ this.#unsubscribeProtection?.();
179
+ this.#publish({ phase: 'stopped', attempt: 0 });
180
+ if (disconnect)
181
+ this.#disconnect();
182
+ }
183
+ async #initialize(generation) {
184
+ let snapshot;
185
+ try {
186
+ snapshot = await this.#client.diagnosticsSnapshot();
187
+ }
188
+ catch {
189
+ // Protected or temporarily unavailable diagnostics must not become a
190
+ // startup dependency. Explicit host gates and the connect result remain.
191
+ }
192
+ if (generation !== this.#generation || this.#stopped)
193
+ return;
194
+ this.#initialized = true;
195
+ if (snapshot !== undefined)
196
+ this.#observeDiagnostics(snapshot, true);
197
+ this.#reconcileHostState();
198
+ }
199
+ #hostBlock() {
200
+ const protection = this.#protection?.current();
201
+ if (this.#diagnosticSecurity === 'preflight' ||
202
+ protection === 'preflight' ||
203
+ (this.#protection !== undefined && protection !== 'active')) {
204
+ return 'protected';
205
+ }
206
+ if (this.#diagnosticConnectivity === 'offline' ||
207
+ this.#connectivity?.current() === 'offline') {
208
+ return 'offline';
209
+ }
210
+ if (this.#lifecycle?.current() === 'background')
211
+ return 'background';
212
+ return undefined;
213
+ }
214
+ #canConnect() {
215
+ return (this.#initialized &&
216
+ !this.#stopped &&
217
+ this.#realtimeSupported &&
218
+ !this.#hostBlock());
219
+ }
220
+ #publish(snapshot) {
221
+ if (this.#snapshot.phase === snapshot.phase &&
222
+ this.#snapshot.attempt === snapshot.attempt &&
223
+ this.#snapshot.retryDelayMs === snapshot.retryDelayMs) {
224
+ return;
225
+ }
226
+ this.#snapshot = Object.freeze(snapshot);
227
+ for (const listener of this.#listeners) {
228
+ try {
229
+ listener();
230
+ }
231
+ catch {
232
+ // Observers cannot alter transport ownership or retry policy.
233
+ }
234
+ }
235
+ }
236
+ #clearRetry() {
237
+ this.#cancelRetry?.();
238
+ this.#cancelRetry = undefined;
239
+ }
240
+ #schedule(delayMs) {
241
+ if (!this.#canConnect() ||
242
+ this.#connected ||
243
+ this.#connecting ||
244
+ this.#cancelRetry) {
245
+ return;
246
+ }
247
+ this.#publish(delayMs > 0
248
+ ? { phase: 'retrying', attempt: this.#attempt, retryDelayMs: delayMs }
249
+ : { phase: 'connecting', attempt: this.#attempt });
250
+ this.#cancelRetry = this.#scheduleTimer(() => {
251
+ this.#cancelRetry = undefined;
252
+ void this.#connect();
253
+ }, delayMs);
254
+ }
255
+ #retryDelay() {
256
+ const exponential = Math.min(this.#maximumDelayMs, this.#initialDelayMs * 2 ** this.#attempt);
257
+ const random = this.#random();
258
+ const boundedRandom = Number.isFinite(random)
259
+ ? Math.max(0, Math.min(1, random))
260
+ : 0;
261
+ const jitter = Math.floor(exponential * 0.25 * boundedRandom);
262
+ return Math.min(this.#maximumDelayMs, exponential + jitter);
263
+ }
264
+ #scheduleRetry() {
265
+ if (!this.#canConnect()) {
266
+ this.#reconcileHostState();
267
+ return;
268
+ }
269
+ const delay = this.#retryDelay();
270
+ this.#attempt = Math.min(32, this.#attempt + 1);
271
+ this.#schedule(delay);
272
+ }
273
+ async #connect() {
274
+ if (!this.#canConnect() || this.#connected || this.#connecting)
275
+ return;
276
+ this.#connecting = true;
277
+ this.#publish({ phase: 'connecting', attempt: this.#attempt });
278
+ const generation = this.#generation;
279
+ let failed = false;
280
+ try {
281
+ await this.#client.connectRealtime();
282
+ this.#transportConnected = true;
283
+ if (generation !== this.#generation || !this.#canConnect()) {
284
+ this.#disconnect();
285
+ return;
286
+ }
287
+ await this.#catchUpConnectedTransport(generation);
288
+ }
289
+ catch {
290
+ failed = true;
291
+ this.#connected = false;
292
+ this.#transportConnected = false;
293
+ this.#disconnect();
294
+ }
295
+ finally {
296
+ this.#connecting = false;
297
+ }
298
+ if (failed && generation === this.#generation)
299
+ this.#scheduleRetry();
300
+ }
301
+ async #catchUpConnectedTransport(generation) {
302
+ await this.#client.syncUntilIdle();
303
+ if (generation !== this.#generation ||
304
+ !this.#canConnect() ||
305
+ !this.#transportConnected) {
306
+ this.#disconnect();
307
+ return;
308
+ }
309
+ this.#connected = true;
310
+ this.#attempt = 0;
311
+ this.#publish({ phase: 'connected', attempt: 0 });
312
+ }
313
+ async #adoptConnectedTransport() {
314
+ if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
315
+ return;
316
+ }
317
+ this.#connecting = true;
318
+ this.#clearRetry();
319
+ this.#publish({ phase: 'connecting', attempt: this.#attempt });
320
+ const generation = this.#generation;
321
+ let failed = false;
322
+ try {
323
+ await this.#catchUpConnectedTransport(generation);
324
+ }
325
+ catch {
326
+ failed = true;
327
+ this.#connected = false;
328
+ this.#transportConnected = false;
329
+ this.#disconnect();
330
+ }
331
+ finally {
332
+ this.#connecting = false;
333
+ }
334
+ if (failed && generation === this.#generation)
335
+ this.#scheduleRetry();
336
+ }
337
+ #observeDiagnostics(snapshot, initial = false) {
338
+ if (this.#stopped)
339
+ return;
340
+ this.#diagnosticConnectivity = snapshot.host.connectivity;
341
+ this.#diagnosticSecurity = snapshot.securityLifecycle;
342
+ if (snapshot.host.realtime === 'unsupported') {
343
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
344
+ this.#realtimeSupported = false;
345
+ this.#generation += 1;
346
+ this.#connected = false;
347
+ this.#transportConnected = false;
348
+ this.#attempt = 0;
349
+ this.#clearRetry();
350
+ this.#publish({ phase: 'unsupported', attempt: 0 });
351
+ if (disconnect)
352
+ this.#disconnect();
353
+ return;
354
+ }
355
+ if (!this.#realtimeSupported)
356
+ return;
357
+ this.#realtimeSupported = true;
358
+ const block = this.#hostBlock();
359
+ if (block) {
360
+ this.#suspend(block);
361
+ return;
362
+ }
363
+ if (snapshot.host.realtime === 'connected') {
364
+ this.#transportConnected = true;
365
+ if (this.#connecting)
366
+ return;
367
+ this.#connected = false;
368
+ void this.#adoptConnectedTransport();
369
+ return;
370
+ }
371
+ if (snapshot.host.realtime === 'disconnected') {
372
+ this.#transportConnected = false;
373
+ this.#connected = false;
374
+ if (this.#connecting)
375
+ return;
376
+ if (this.#initialized && !initial)
377
+ this.#scheduleRetry();
378
+ return;
379
+ }
380
+ this.#reconcileHostState();
381
+ }
382
+ #reconcileHostState() {
383
+ if (this.#stopped)
384
+ return;
385
+ const block = this.#hostBlock();
386
+ if (block) {
387
+ this.#suspend(block);
388
+ return;
389
+ }
390
+ if (!this.#initialized) {
391
+ this.#publish({ phase: 'idle', attempt: 0 });
392
+ return;
393
+ }
394
+ if (!this.#realtimeSupported) {
395
+ this.#publish({ phase: 'unsupported', attempt: 0 });
396
+ return;
397
+ }
398
+ if (this.#connected) {
399
+ this.#publish({ phase: 'connected', attempt: 0 });
400
+ return;
401
+ }
402
+ this.#schedule(0);
403
+ }
404
+ #suspend(phase) {
405
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
406
+ const hadScheduledWork = this.#cancelRetry !== undefined;
407
+ const phaseChanged = this.#snapshot.phase !== phase;
408
+ if (!disconnect && !hadScheduledWork && !phaseChanged)
409
+ return;
410
+ this.#generation += 1;
411
+ this.#connected = false;
412
+ this.#transportConnected = false;
413
+ this.#connecting = false;
414
+ this.#attempt = 0;
415
+ this.#clearRetry();
416
+ this.#publish({ phase, attempt: 0 });
417
+ if (disconnect)
418
+ this.#disconnect();
419
+ }
420
+ #disconnect() {
421
+ void Promise.resolve(this.#client.disconnectRealtime()).catch(() => undefined);
422
+ }
423
+ }
424
+ /** Install one supervisor and make client disposal cancel it before close. */
425
+ export function installRealtimeSupervisor(client, options) {
426
+ if (attachment(client))
427
+ return client;
428
+ const supervisor = new RealtimeSupervisor(client, options);
429
+ Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
430
+ configurable: false,
431
+ enumerable: false,
432
+ writable: false,
433
+ value: { version: 1, supervisor },
434
+ });
435
+ const close = client.close.bind(client);
436
+ Object.defineProperty(client, 'close', {
437
+ configurable: true,
438
+ writable: true,
439
+ value: async () => {
440
+ supervisor.stop();
441
+ await close();
442
+ },
443
+ });
444
+ supervisor.start();
445
+ return client;
446
+ }
447
+ export function realtimeSupervisorSnapshot(client) {
448
+ return attachment(client)?.supervisor.snapshot() ?? UNSUPPORTED_SNAPSHOT;
449
+ }
450
+ export function subscribeRealtimeSupervisor(client, listener) {
451
+ return (attachment(client)?.supervisor.subscribe(listener) ?? (() => undefined));
452
+ }