@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.
- package/README.md +45 -5
- package/dist/client.js +43 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/local-rebootstrap-receipt.d.ts +12 -0
- package/dist/local-rebootstrap-receipt.js +59 -0
- package/dist/local-rebootstrap.d.ts +5 -1
- package/dist/realtime-supervisor.d.ts +78 -0
- package/dist/realtime-supervisor.js +452 -0
- package/package.json +3 -3
- package/src/client.ts +56 -8
- package/src/index.ts +1 -0
- package/src/local-rebootstrap-receipt.ts +79 -0
- package/src/local-rebootstrap.ts +5 -1
- package/src/realtime-supervisor.ts +598 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
2251
|
-
resetSubscriptions:
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|