@syncular/client 0.15.36 → 0.15.38

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.
@@ -0,0 +1,468 @@
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
+ const observationSources = new WeakMap();
10
+ function attachment(client, visited = new Set()) {
11
+ if (visited.has(client))
12
+ return undefined;
13
+ visited.add(client);
14
+ const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY);
15
+ if (candidate?.version === 1 && candidate.supervisor) {
16
+ return candidate;
17
+ }
18
+ const source = observationSources.get(client);
19
+ return source === undefined ? undefined : attachment(source, visited);
20
+ }
21
+ /**
22
+ * Preserve supervisor observation across a facade without transferring
23
+ * transport ownership or exposing the source client. Binding packages use
24
+ * this when they normalize a client into another object identity.
25
+ */
26
+ export function linkRealtimeSupervisorObservation(target, source) {
27
+ if (target !== source)
28
+ observationSources.set(target, source);
29
+ return target;
30
+ }
31
+ function scheduleTimer(callback, delayMs) {
32
+ const timer = globalThis.setTimeout(callback, delayMs);
33
+ return () => globalThis.clearTimeout(timer);
34
+ }
35
+ function boundedDelay(value, fallback) {
36
+ if (value === undefined)
37
+ return fallback;
38
+ if (!Number.isSafeInteger(value) || value < 1) {
39
+ throw new TypeError('realtime supervisor delays must be positive safe integers');
40
+ }
41
+ return Math.min(value, MAXIMUM_CONFIGURED_DELAY_MS);
42
+ }
43
+ function eventTarget(value) {
44
+ const candidate = value;
45
+ return typeof candidate?.addEventListener === 'function' &&
46
+ typeof candidate.removeEventListener === 'function'
47
+ ? candidate
48
+ : undefined;
49
+ }
50
+ /** Browser/Tauri-webview connectivity evidence without importing DOM types. */
51
+ export function browserConnectivitySignal(options = {}) {
52
+ const root = globalThis;
53
+ const events = options.events ?? eventTarget(globalThis);
54
+ const network = options.network ?? root.navigator;
55
+ const current = () => {
56
+ if (network?.onLine === true)
57
+ return 'online';
58
+ if (network?.onLine === false)
59
+ return 'offline';
60
+ return 'unknown';
61
+ };
62
+ return {
63
+ current,
64
+ subscribe(listener) {
65
+ if (events === undefined)
66
+ return () => undefined;
67
+ const notify = () => listener(current());
68
+ events.addEventListener('online', notify);
69
+ events.addEventListener('offline', notify);
70
+ return () => {
71
+ events.removeEventListener('online', notify);
72
+ events.removeEventListener('offline', notify);
73
+ };
74
+ },
75
+ };
76
+ }
77
+ /** Browser/Tauri-webview visibility evidence without importing DOM types. */
78
+ export function documentLifecycleSignal(options = {}) {
79
+ const root = globalThis;
80
+ const document = options.document ?? root.document;
81
+ const events = options.events ?? eventTarget(document);
82
+ const classify = () => {
83
+ if (document?.visibilityState === 'visible')
84
+ return 'active';
85
+ if (document?.visibilityState === 'hidden')
86
+ return 'background';
87
+ return 'unknown';
88
+ };
89
+ let state = classify();
90
+ return {
91
+ current: () => state,
92
+ subscribe(listener) {
93
+ if (events === undefined)
94
+ return () => undefined;
95
+ const visibility = () => {
96
+ state = classify();
97
+ listener(state);
98
+ };
99
+ const background = () => {
100
+ state = 'background';
101
+ listener(state);
102
+ };
103
+ const active = () => {
104
+ state = classify() === 'background' ? 'background' : 'active';
105
+ listener(state);
106
+ };
107
+ events.addEventListener('visibilitychange', visibility);
108
+ events.addEventListener('pagehide', background);
109
+ events.addEventListener('pageshow', active);
110
+ return () => {
111
+ events.removeEventListener('visibilitychange', visibility);
112
+ events.removeEventListener('pagehide', background);
113
+ events.removeEventListener('pageshow', active);
114
+ };
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Supported host policy for Syncular's explicit realtime transport. It owns
120
+ * exactly one connect attempt, runs an explicit catch-up round before claiming
121
+ * connected, retries transient loss with bounded exponential jitter, and
122
+ * suspends across offline, background, or protected-preflight state.
123
+ */
124
+ export class RealtimeSupervisor {
125
+ #client;
126
+ #connectivity;
127
+ #lifecycle;
128
+ #protection;
129
+ #scheduleTimer;
130
+ #random;
131
+ #initialDelayMs;
132
+ #maximumDelayMs;
133
+ #listeners = new Set();
134
+ #started = false;
135
+ #initialized = false;
136
+ #stopped = false;
137
+ #connected = false;
138
+ #transportConnected = false;
139
+ #connecting = false;
140
+ #realtimeSupported = true;
141
+ #diagnosticConnectivity = 'unknown';
142
+ #diagnosticSecurity = 'unknown';
143
+ #attempt = 0;
144
+ #generation = 0;
145
+ #snapshot = { phase: 'idle', attempt: 0 };
146
+ #cancelRetry;
147
+ #unsubscribeDiagnostics;
148
+ #unsubscribeConnectivity;
149
+ #unsubscribeLifecycle;
150
+ #unsubscribeProtection;
151
+ constructor(client, options = {}) {
152
+ this.#client = client;
153
+ this.#connectivity = options.connectivity;
154
+ this.#lifecycle = options.lifecycle;
155
+ this.#protection = options.protection;
156
+ this.#scheduleTimer = options.schedule ?? scheduleTimer;
157
+ this.#random = options.random ?? Math.random;
158
+ this.#initialDelayMs = boundedDelay(options.initialDelayMs, DEFAULT_INITIAL_DELAY_MS);
159
+ this.#maximumDelayMs = Math.max(this.#initialDelayMs, boundedDelay(options.maximumDelayMs, DEFAULT_MAXIMUM_DELAY_MS));
160
+ }
161
+ snapshot() {
162
+ return this.#snapshot;
163
+ }
164
+ subscribe(listener) {
165
+ this.#listeners.add(listener);
166
+ return () => this.#listeners.delete(listener);
167
+ }
168
+ start() {
169
+ if (this.#started || this.#stopped)
170
+ return;
171
+ this.#started = true;
172
+ this.#unsubscribeDiagnostics = this.#client.onDiagnostics((snapshot) => this.#observeDiagnostics(snapshot));
173
+ this.#unsubscribeConnectivity = this.#connectivity?.subscribe(() => this.#reconcileHostState());
174
+ this.#unsubscribeLifecycle = this.#lifecycle?.subscribe(() => this.#reconcileHostState());
175
+ this.#unsubscribeProtection = this.#protection?.subscribe(() => this.#reconcileHostState());
176
+ this.#reconcileHostState();
177
+ const generation = this.#generation;
178
+ void this.#initialize(generation);
179
+ }
180
+ stop() {
181
+ if (this.#stopped)
182
+ return;
183
+ this.#stopped = true;
184
+ this.#generation += 1;
185
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
186
+ this.#connected = false;
187
+ this.#transportConnected = false;
188
+ this.#connecting = false;
189
+ this.#attempt = 0;
190
+ this.#clearRetry();
191
+ this.#unsubscribeDiagnostics?.();
192
+ this.#unsubscribeConnectivity?.();
193
+ this.#unsubscribeLifecycle?.();
194
+ this.#unsubscribeProtection?.();
195
+ this.#publish({ phase: 'stopped', attempt: 0 });
196
+ if (disconnect)
197
+ this.#disconnect();
198
+ }
199
+ async #initialize(generation) {
200
+ let snapshot;
201
+ try {
202
+ snapshot = await this.#client.diagnosticsSnapshot();
203
+ }
204
+ catch {
205
+ // Protected or temporarily unavailable diagnostics must not become a
206
+ // startup dependency. Explicit host gates and the connect result remain.
207
+ }
208
+ if (generation !== this.#generation || this.#stopped)
209
+ return;
210
+ this.#initialized = true;
211
+ if (snapshot !== undefined)
212
+ this.#observeDiagnostics(snapshot, true);
213
+ this.#reconcileHostState();
214
+ }
215
+ #hostBlock() {
216
+ const protection = this.#protection?.current();
217
+ if (this.#diagnosticSecurity === 'preflight' ||
218
+ protection === 'preflight' ||
219
+ (this.#protection !== undefined && protection !== 'active')) {
220
+ return 'protected';
221
+ }
222
+ if (this.#diagnosticConnectivity === 'offline' ||
223
+ this.#connectivity?.current() === 'offline') {
224
+ return 'offline';
225
+ }
226
+ if (this.#lifecycle?.current() === 'background')
227
+ return 'background';
228
+ return undefined;
229
+ }
230
+ #canConnect() {
231
+ return (this.#initialized &&
232
+ !this.#stopped &&
233
+ this.#realtimeSupported &&
234
+ !this.#hostBlock());
235
+ }
236
+ #publish(snapshot) {
237
+ if (this.#snapshot.phase === snapshot.phase &&
238
+ this.#snapshot.attempt === snapshot.attempt &&
239
+ this.#snapshot.retryDelayMs === snapshot.retryDelayMs) {
240
+ return;
241
+ }
242
+ this.#snapshot = Object.freeze(snapshot);
243
+ for (const listener of this.#listeners) {
244
+ try {
245
+ listener();
246
+ }
247
+ catch {
248
+ // Observers cannot alter transport ownership or retry policy.
249
+ }
250
+ }
251
+ }
252
+ #clearRetry() {
253
+ this.#cancelRetry?.();
254
+ this.#cancelRetry = undefined;
255
+ }
256
+ #schedule(delayMs) {
257
+ if (!this.#canConnect() ||
258
+ this.#connected ||
259
+ this.#connecting ||
260
+ this.#cancelRetry) {
261
+ return;
262
+ }
263
+ this.#publish(delayMs > 0
264
+ ? { phase: 'retrying', attempt: this.#attempt, retryDelayMs: delayMs }
265
+ : { phase: 'connecting', attempt: this.#attempt });
266
+ this.#cancelRetry = this.#scheduleTimer(() => {
267
+ this.#cancelRetry = undefined;
268
+ void this.#connect();
269
+ }, delayMs);
270
+ }
271
+ #retryDelay() {
272
+ const exponential = Math.min(this.#maximumDelayMs, this.#initialDelayMs * 2 ** this.#attempt);
273
+ const random = this.#random();
274
+ const boundedRandom = Number.isFinite(random)
275
+ ? Math.max(0, Math.min(1, random))
276
+ : 0;
277
+ const jitter = Math.floor(exponential * 0.25 * boundedRandom);
278
+ return Math.min(this.#maximumDelayMs, exponential + jitter);
279
+ }
280
+ #scheduleRetry() {
281
+ if (!this.#canConnect()) {
282
+ this.#reconcileHostState();
283
+ return;
284
+ }
285
+ const delay = this.#retryDelay();
286
+ this.#attempt = Math.min(32, this.#attempt + 1);
287
+ this.#schedule(delay);
288
+ }
289
+ async #connect() {
290
+ if (!this.#canConnect() || this.#connected || this.#connecting)
291
+ return;
292
+ this.#connecting = true;
293
+ this.#publish({ phase: 'connecting', attempt: this.#attempt });
294
+ const generation = this.#generation;
295
+ let failed = false;
296
+ try {
297
+ await this.#client.connectRealtime();
298
+ this.#transportConnected = true;
299
+ if (generation !== this.#generation || !this.#canConnect()) {
300
+ this.#disconnect();
301
+ return;
302
+ }
303
+ await this.#catchUpConnectedTransport(generation);
304
+ }
305
+ catch {
306
+ failed = true;
307
+ this.#connected = false;
308
+ this.#transportConnected = false;
309
+ this.#disconnect();
310
+ }
311
+ finally {
312
+ this.#connecting = false;
313
+ }
314
+ if (failed && generation === this.#generation)
315
+ this.#scheduleRetry();
316
+ }
317
+ async #catchUpConnectedTransport(generation) {
318
+ await this.#client.syncUntilIdle();
319
+ if (generation !== this.#generation ||
320
+ !this.#canConnect() ||
321
+ !this.#transportConnected) {
322
+ this.#disconnect();
323
+ return;
324
+ }
325
+ this.#connected = true;
326
+ this.#attempt = 0;
327
+ this.#publish({ phase: 'connected', attempt: 0 });
328
+ }
329
+ async #adoptConnectedTransport() {
330
+ if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
331
+ return;
332
+ }
333
+ this.#connecting = true;
334
+ this.#clearRetry();
335
+ this.#publish({ phase: 'connecting', attempt: this.#attempt });
336
+ const generation = this.#generation;
337
+ let failed = false;
338
+ try {
339
+ await this.#catchUpConnectedTransport(generation);
340
+ }
341
+ catch {
342
+ failed = true;
343
+ this.#connected = false;
344
+ this.#transportConnected = false;
345
+ this.#disconnect();
346
+ }
347
+ finally {
348
+ this.#connecting = false;
349
+ }
350
+ if (failed && generation === this.#generation)
351
+ this.#scheduleRetry();
352
+ }
353
+ #observeDiagnostics(snapshot, initial = false) {
354
+ if (this.#stopped)
355
+ return;
356
+ this.#diagnosticConnectivity = snapshot.host.connectivity;
357
+ this.#diagnosticSecurity = snapshot.securityLifecycle;
358
+ if (snapshot.host.realtime === 'unsupported') {
359
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
360
+ this.#realtimeSupported = false;
361
+ this.#generation += 1;
362
+ this.#connected = false;
363
+ this.#transportConnected = false;
364
+ this.#attempt = 0;
365
+ this.#clearRetry();
366
+ this.#publish({ phase: 'unsupported', attempt: 0 });
367
+ if (disconnect)
368
+ this.#disconnect();
369
+ return;
370
+ }
371
+ if (!this.#realtimeSupported)
372
+ return;
373
+ this.#realtimeSupported = true;
374
+ const block = this.#hostBlock();
375
+ if (block) {
376
+ this.#suspend(block);
377
+ return;
378
+ }
379
+ if (snapshot.host.realtime === 'connected') {
380
+ this.#transportConnected = true;
381
+ if (this.#connecting)
382
+ return;
383
+ this.#connected = false;
384
+ void this.#adoptConnectedTransport();
385
+ return;
386
+ }
387
+ if (snapshot.host.realtime === 'disconnected') {
388
+ this.#transportConnected = false;
389
+ this.#connected = false;
390
+ if (this.#connecting)
391
+ return;
392
+ if (this.#initialized && !initial)
393
+ this.#scheduleRetry();
394
+ return;
395
+ }
396
+ this.#reconcileHostState();
397
+ }
398
+ #reconcileHostState() {
399
+ if (this.#stopped)
400
+ return;
401
+ const block = this.#hostBlock();
402
+ if (block) {
403
+ this.#suspend(block);
404
+ return;
405
+ }
406
+ if (!this.#initialized) {
407
+ this.#publish({ phase: 'idle', attempt: 0 });
408
+ return;
409
+ }
410
+ if (!this.#realtimeSupported) {
411
+ this.#publish({ phase: 'unsupported', attempt: 0 });
412
+ return;
413
+ }
414
+ if (this.#connected) {
415
+ this.#publish({ phase: 'connected', attempt: 0 });
416
+ return;
417
+ }
418
+ this.#schedule(0);
419
+ }
420
+ #suspend(phase) {
421
+ const disconnect = this.#connected || this.#transportConnected || this.#connecting;
422
+ const hadScheduledWork = this.#cancelRetry !== undefined;
423
+ const phaseChanged = this.#snapshot.phase !== phase;
424
+ if (!disconnect && !hadScheduledWork && !phaseChanged)
425
+ return;
426
+ this.#generation += 1;
427
+ this.#connected = false;
428
+ this.#transportConnected = false;
429
+ this.#connecting = false;
430
+ this.#attempt = 0;
431
+ this.#clearRetry();
432
+ this.#publish({ phase, attempt: 0 });
433
+ if (disconnect)
434
+ this.#disconnect();
435
+ }
436
+ #disconnect() {
437
+ void Promise.resolve(this.#client.disconnectRealtime()).catch(() => undefined);
438
+ }
439
+ }
440
+ /** Install one supervisor and make client disposal cancel it before close. */
441
+ export function installRealtimeSupervisor(client, options) {
442
+ if (attachment(client))
443
+ return client;
444
+ const supervisor = new RealtimeSupervisor(client, options);
445
+ Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
446
+ configurable: false,
447
+ enumerable: false,
448
+ writable: false,
449
+ value: { version: 1, supervisor },
450
+ });
451
+ const close = client.close.bind(client);
452
+ Object.defineProperty(client, 'close', {
453
+ configurable: true,
454
+ writable: true,
455
+ value: async () => {
456
+ supervisor.stop();
457
+ await close();
458
+ },
459
+ });
460
+ supervisor.start();
461
+ return client;
462
+ }
463
+ export function realtimeSupervisorSnapshot(client) {
464
+ return attachment(client)?.supervisor.snapshot() ?? UNSUPPORTED_SNAPSHOT;
465
+ }
466
+ export function subscribeRealtimeSupervisor(client, listener) {
467
+ return (attachment(client)?.supervisor.subscribe(listener) ?? (() => undefined));
468
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.36",
3
+ "version": "0.15.38",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.36"
84
+ "@syncular/core": "0.15.38"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.36",
95
+ "@syncular/server": "0.15.38",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -545,6 +545,8 @@ export class SyncClient {
545
545
  #conflicts: ConflictRecord[] = [];
546
546
  #rejections: RejectionRecord[] = [];
547
547
  #socket: RealtimeSocket | undefined;
548
+ #realtimeConnectPromise: Promise<void> | undefined;
549
+ #realtimeGeneration = 0;
548
550
  #pendingRound: PendingRound | undefined;
549
551
  #needsPull = false;
550
552
  #syncing = false;
@@ -793,8 +795,7 @@ export class SyncClient {
793
795
  async close(): Promise<void> {
794
796
  this.#devtoolsUnregister?.();
795
797
  this.#devtoolsUnregister = undefined;
796
- this.#socket?.close();
797
- this.#socket = undefined;
798
+ this.disconnectRealtime();
798
799
  this.#abortPendingRound('client closed mid-round');
799
800
  await this.#lease?.release();
800
801
  this.#lease = undefined;
@@ -2758,10 +2759,32 @@ export class SyncClient {
2758
2759
  // -- realtime (§8 client side) ----------------------------------------------
2759
2760
 
2760
2761
  connectRealtime(): Promise<void> {
2761
- return this.#runProtectedAsync(() => this.#connectRealtime());
2762
+ this.#requireActive();
2763
+ if (this.#socket !== undefined) return Promise.resolve();
2764
+ if (this.#realtimeConnectPromise !== undefined) {
2765
+ return this.#realtimeConnectPromise;
2766
+ }
2767
+ const generation = this.#realtimeGeneration;
2768
+ const task = this.#runProtectedAsync(() =>
2769
+ this.#connectRealtime(generation),
2770
+ );
2771
+ this.#realtimeConnectPromise = task;
2772
+ void task.then(
2773
+ () => {
2774
+ if (this.#realtimeConnectPromise === task) {
2775
+ this.#realtimeConnectPromise = undefined;
2776
+ }
2777
+ },
2778
+ () => {
2779
+ if (this.#realtimeConnectPromise === task) {
2780
+ this.#realtimeConnectPromise = undefined;
2781
+ }
2782
+ },
2783
+ );
2784
+ return task;
2762
2785
  }
2763
2786
 
2764
- async #connectRealtime(): Promise<void> {
2787
+ async #connectRealtime(generation: number): Promise<void> {
2765
2788
  const connector = this.#config.realtime;
2766
2789
  if (connector === undefined) {
2767
2790
  throw new ClientSyncError(
@@ -2769,16 +2792,19 @@ export class SyncClient {
2769
2792
  'no realtime connector configured',
2770
2793
  );
2771
2794
  }
2795
+ let openedSocket: RealtimeSocket | undefined;
2772
2796
  const socket = await connector({
2773
2797
  onText: (text) => this.#handleRealtimeText(text),
2774
2798
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2775
2799
  onClose: () => {
2800
+ if (openedSocket === undefined || this.#socket !== openedSocket) return;
2776
2801
  this.#socket = undefined;
2777
2802
  this.#presence.clear(); // §8.6.1: presence is per-connection
2778
2803
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2779
2804
  this.#emitDiagnostics();
2780
2805
  },
2781
2806
  });
2807
+ openedSocket = socket;
2782
2808
  if (this.#securityLifecycle === 'preflight') {
2783
2809
  socket.close();
2784
2810
  throw new ClientSyncError(
@@ -2786,11 +2812,20 @@ export class SyncClient {
2786
2812
  'realtime connected after the client entered security preflight',
2787
2813
  );
2788
2814
  }
2815
+ if (generation !== this.#realtimeGeneration || !this.#started) {
2816
+ socket.close();
2817
+ throw new ClientSyncError(
2818
+ 'client.realtime_cancelled',
2819
+ 'realtime connection was cancelled before activation',
2820
+ );
2821
+ }
2789
2822
  this.#socket = socket;
2790
2823
  this.#emitDiagnostics();
2791
2824
  }
2792
2825
 
2793
2826
  disconnectRealtime(): void {
2827
+ this.#realtimeGeneration += 1;
2828
+ this.#realtimeConnectPromise = undefined;
2794
2829
  this.#socket?.close();
2795
2830
  this.#socket = undefined;
2796
2831
  this.#presence.clear(); // §8.6.1: presence is per-connection
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from './outbox';
29
29
  export * from './outcomes';
30
30
  export * from './query-guard';
31
31
  export * from './reactive-store';
32
+ export * from './realtime-supervisor';
32
33
  export * from './schema';
33
34
  export * from './sql-tag';
34
35
  export * from './state';