@syncular/client 0.15.35 → 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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.35",
3
+ "version": "0.15.37",
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.35"
84
+ "@syncular/core": "0.15.37"
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.35",
95
+ "@syncular/server": "0.15.37",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/client.ts CHANGED
@@ -109,6 +109,10 @@ import {
109
109
  type LocalDataRebootstrapResult,
110
110
  localDataRebootstrapMetaKey,
111
111
  } from './local-rebootstrap';
112
+ import {
113
+ decodeLocalDataRebootstrapReceipt,
114
+ encodeLocalDataRebootstrapReceipt,
115
+ } from './local-rebootstrap-receipt';
112
116
  import {
113
117
  appendOutboxCommit,
114
118
  deleteOutboxCommit,
@@ -541,6 +545,8 @@ export class SyncClient {
541
545
  #conflicts: ConflictRecord[] = [];
542
546
  #rejections: RejectionRecord[] = [];
543
547
  #socket: RealtimeSocket | undefined;
548
+ #realtimeConnectPromise: Promise<void> | undefined;
549
+ #realtimeGeneration = 0;
544
550
  #pendingRound: PendingRound | undefined;
545
551
  #needsPull = false;
546
552
  #syncing = false;
@@ -789,8 +795,7 @@ export class SyncClient {
789
795
  async close(): Promise<void> {
790
796
  this.#devtoolsUnregister?.();
791
797
  this.#devtoolsUnregister = undefined;
792
- this.#socket?.close();
793
- this.#socket = undefined;
798
+ this.disconnectRealtime();
794
799
  this.#abortPendingRound('client closed mid-round');
795
800
  await this.#lease?.release();
796
801
  this.#lease = undefined;
@@ -2244,11 +2249,13 @@ export class SyncClient {
2244
2249
  this.#requireActive();
2245
2250
  const rebootstrapId = compileLocalDataRebootstrap(input);
2246
2251
  const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
2247
- if (getMeta(this.#db, metaKey) !== undefined) {
2252
+ const persistedReceipt = getMeta(this.#db, metaKey);
2253
+ if (persistedReceipt !== undefined) {
2254
+ const receipt = decodeLocalDataRebootstrapReceipt(persistedReceipt);
2248
2255
  return {
2249
2256
  alreadyApplied: true,
2250
- retainedCommits: 0,
2251
- resetSubscriptions: 0,
2257
+ retainedCommits: receipt.retainedCommits,
2258
+ resetSubscriptions: receipt.resetSubscriptions,
2252
2259
  };
2253
2260
  }
2254
2261
  if (this.#schemaFloor !== undefined) {
@@ -2275,7 +2282,14 @@ export class SyncClient {
2275
2282
  for (const commit of pending) {
2276
2283
  this.#applyOperationsLocally(commit.operations, batch);
2277
2284
  }
2278
- setMeta(this.#db, metaKey, 'v1');
2285
+ setMeta(
2286
+ this.#db,
2287
+ metaKey,
2288
+ encodeLocalDataRebootstrapReceipt({
2289
+ retainedCommits: pending.length,
2290
+ resetSubscriptions,
2291
+ }),
2292
+ );
2279
2293
  });
2280
2294
  } catch (error) {
2281
2295
  this.#upgrading = priorUpgrading;
@@ -2745,10 +2759,32 @@ export class SyncClient {
2745
2759
  // -- realtime (§8 client side) ----------------------------------------------
2746
2760
 
2747
2761
  connectRealtime(): Promise<void> {
2748
- 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;
2749
2785
  }
2750
2786
 
2751
- async #connectRealtime(): Promise<void> {
2787
+ async #connectRealtime(generation: number): Promise<void> {
2752
2788
  const connector = this.#config.realtime;
2753
2789
  if (connector === undefined) {
2754
2790
  throw new ClientSyncError(
@@ -2756,16 +2792,19 @@ export class SyncClient {
2756
2792
  'no realtime connector configured',
2757
2793
  );
2758
2794
  }
2795
+ let openedSocket: RealtimeSocket | undefined;
2759
2796
  const socket = await connector({
2760
2797
  onText: (text) => this.#handleRealtimeText(text),
2761
2798
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
2762
2799
  onClose: () => {
2800
+ if (openedSocket === undefined || this.#socket !== openedSocket) return;
2763
2801
  this.#socket = undefined;
2764
2802
  this.#presence.clear(); // §8.6.1: presence is per-connection
2765
2803
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
2766
2804
  this.#emitDiagnostics();
2767
2805
  },
2768
2806
  });
2807
+ openedSocket = socket;
2769
2808
  if (this.#securityLifecycle === 'preflight') {
2770
2809
  socket.close();
2771
2810
  throw new ClientSyncError(
@@ -2773,11 +2812,20 @@ export class SyncClient {
2773
2812
  'realtime connected after the client entered security preflight',
2774
2813
  );
2775
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
+ }
2776
2822
  this.#socket = socket;
2777
2823
  this.#emitDiagnostics();
2778
2824
  }
2779
2825
 
2780
2826
  disconnectRealtime(): void {
2827
+ this.#realtimeGeneration += 1;
2828
+ this.#realtimeConnectPromise = undefined;
2781
2829
  this.#socket?.close();
2782
2830
  this.#socket = undefined;
2783
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';
@@ -0,0 +1,79 @@
1
+ import { ClientSyncError } from './errors';
2
+
3
+ const LEGACY_MARKER = 'v1';
4
+ const RECEIPT_VERSION = 2;
5
+ const RECEIPT_KEYS = [
6
+ 'resetSubscriptions',
7
+ 'retainedCommits',
8
+ 'version',
9
+ ] as const;
10
+
11
+ export interface LocalDataRebootstrapReceipt {
12
+ readonly retainedCommits: number;
13
+ readonly resetSubscriptions: number;
14
+ }
15
+
16
+ function invalidReceipt(): never {
17
+ throw new ClientSyncError(
18
+ 'sync.local_corrupt',
19
+ 'persisted local rebootstrap receipt is invalid',
20
+ );
21
+ }
22
+
23
+ function isCount(value: unknown): value is number {
24
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
25
+ }
26
+
27
+ /** Encode only the bounded counts that are safe to replay outside the core. */
28
+ export function encodeLocalDataRebootstrapReceipt(
29
+ receipt: LocalDataRebootstrapReceipt,
30
+ ): string {
31
+ if (
32
+ !isCount(receipt.retainedCommits) ||
33
+ !isCount(receipt.resetSubscriptions)
34
+ ) {
35
+ return invalidReceipt();
36
+ }
37
+ return JSON.stringify({
38
+ version: RECEIPT_VERSION,
39
+ retainedCommits: receipt.retainedCommits,
40
+ resetSubscriptions: receipt.resetSubscriptions,
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Decode a committed receipt without leaking its application-owned key. The
46
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
47
+ * historical repairs keep their former zero-count replay behavior.
48
+ */
49
+ export function decodeLocalDataRebootstrapReceipt(
50
+ value: string,
51
+ ): LocalDataRebootstrapReceipt {
52
+ if (value === LEGACY_MARKER) {
53
+ return { retainedCommits: 0, resetSubscriptions: 0 };
54
+ }
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(value);
58
+ } catch {
59
+ return invalidReceipt();
60
+ }
61
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
62
+ return invalidReceipt();
63
+ }
64
+ const source = parsed as Record<string, unknown>;
65
+ const keys = Object.keys(source).sort();
66
+ if (
67
+ keys.length !== RECEIPT_KEYS.length ||
68
+ keys.some((key, index) => key !== RECEIPT_KEYS[index]) ||
69
+ source.version !== RECEIPT_VERSION ||
70
+ !isCount(source.retainedCommits) ||
71
+ !isCount(source.resetSubscriptions)
72
+ ) {
73
+ return invalidReceipt();
74
+ }
75
+ return {
76
+ retainedCommits: source.retainedCommits,
77
+ resetSubscriptions: source.resetSubscriptions,
78
+ };
79
+ }