@syncular/client 0.0.2-2 → 0.0.3-12
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/dist/client.d.ts +69 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +104 -0
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +57 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +707 -9
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +115 -2
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pull-engine.js +1 -1
- package/dist/pull-engine.js.map +1 -1
- package/dist/subscription-state.d.ts +46 -0
- package/dist/subscription-state.d.ts.map +1 -0
- package/dist/subscription-state.js +185 -0
- package/dist/subscription-state.js.map +1 -0
- package/dist/utils/id.d.ts +9 -0
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +27 -0
- package/dist/utils/id.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +39 -0
- package/src/client.ts +158 -0
- package/src/engine/SyncEngine.test.ts +39 -0
- package/src/engine/SyncEngine.ts +906 -21
- package/src/engine/types.ts +150 -1
- package/src/index.ts +1 -0
- package/src/pull-engine.ts +1 -1
- package/src/subscription-state.ts +259 -0
- package/src/utils/id.ts +35 -0
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Event-driven sync engine that manages push/pull cycles, connection state,
|
|
5
5
|
* and provides a clean API for framework bindings to consume.
|
|
6
6
|
*/
|
|
7
|
-
import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, startSyncSpan, } from '@syncular/core';
|
|
7
|
+
import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, SyncTransportError, startSyncSpan, } from '@syncular/core';
|
|
8
8
|
import { sql } from 'kysely';
|
|
9
9
|
import { syncPushOnce } from '../push-engine.js';
|
|
10
|
+
import { DEFAULT_SYNC_STATE_ID, getSubscriptionState as readSubscriptionState, listSubscriptionStates as readSubscriptionStates, } from '../subscription-state.js';
|
|
10
11
|
import { syncOnce } from '../sync-loop.js';
|
|
11
12
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
12
13
|
const DEFAULT_MAX_RETRIES = 5;
|
|
@@ -14,6 +15,9 @@ const INITIAL_RETRY_DELAY_MS = 1000;
|
|
|
14
15
|
const MAX_RETRY_DELAY_MS = 60000;
|
|
15
16
|
const EXPONENTIAL_FACTOR = 2;
|
|
16
17
|
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
18
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
|
|
19
|
+
const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
|
|
20
|
+
const MAX_INSPECTOR_EVENT_LIMIT = 500;
|
|
17
21
|
function calculateRetryDelay(attemptIndex) {
|
|
18
22
|
return Math.min(INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex, MAX_RETRY_DELAY_MS);
|
|
19
23
|
}
|
|
@@ -22,17 +26,115 @@ function isRealtimeTransport(transport) {
|
|
|
22
26
|
transport !== null &&
|
|
23
27
|
typeof transport.connect === 'function');
|
|
24
28
|
}
|
|
25
|
-
function createSyncError(
|
|
29
|
+
function createSyncError(args) {
|
|
26
30
|
return {
|
|
27
|
-
code,
|
|
31
|
+
code: args.code,
|
|
32
|
+
message: args.message,
|
|
33
|
+
cause: args.cause,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
retryable: args.retryable ?? false,
|
|
36
|
+
httpStatus: args.httpStatus,
|
|
37
|
+
subscriptionId: args.subscriptionId,
|
|
38
|
+
stateId: args.stateId,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function classifySyncFailure(error) {
|
|
42
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
43
|
+
const message = cause.message || 'Sync failed';
|
|
44
|
+
const normalized = message.toLowerCase();
|
|
45
|
+
if (cause instanceof SyncTransportError) {
|
|
46
|
+
if (cause.status === 401 || cause.status === 403) {
|
|
47
|
+
return {
|
|
48
|
+
code: 'AUTH_FAILED',
|
|
49
|
+
message,
|
|
50
|
+
cause,
|
|
51
|
+
retryable: false,
|
|
52
|
+
httpStatus: cause.status,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (cause.status === 404 &&
|
|
56
|
+
normalized.includes('snapshot') &&
|
|
57
|
+
normalized.includes('chunk')) {
|
|
58
|
+
return {
|
|
59
|
+
code: 'SNAPSHOT_CHUNK_NOT_FOUND',
|
|
60
|
+
message,
|
|
61
|
+
cause,
|
|
62
|
+
retryable: false,
|
|
63
|
+
httpStatus: cause.status,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (cause.status !== undefined &&
|
|
67
|
+
(cause.status >= 500 || cause.status === 408 || cause.status === 429)) {
|
|
68
|
+
return {
|
|
69
|
+
code: 'NETWORK_ERROR',
|
|
70
|
+
message,
|
|
71
|
+
cause,
|
|
72
|
+
retryable: true,
|
|
73
|
+
httpStatus: cause.status,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
code: 'SYNC_ERROR',
|
|
78
|
+
message,
|
|
79
|
+
cause,
|
|
80
|
+
retryable: false,
|
|
81
|
+
httpStatus: cause.status,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (normalized.includes('network') ||
|
|
85
|
+
normalized.includes('fetch') ||
|
|
86
|
+
normalized.includes('timeout') ||
|
|
87
|
+
normalized.includes('offline')) {
|
|
88
|
+
return {
|
|
89
|
+
code: 'NETWORK_ERROR',
|
|
90
|
+
message,
|
|
91
|
+
cause,
|
|
92
|
+
retryable: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (normalized.includes('conflict')) {
|
|
96
|
+
return {
|
|
97
|
+
code: 'CONFLICT',
|
|
98
|
+
message,
|
|
99
|
+
cause,
|
|
100
|
+
retryable: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
code: 'SYNC_ERROR',
|
|
28
105
|
message,
|
|
29
106
|
cause,
|
|
30
|
-
|
|
107
|
+
retryable: false,
|
|
31
108
|
};
|
|
32
109
|
}
|
|
33
110
|
function resolveSyncTriggerLabel(trigger) {
|
|
34
111
|
return trigger ?? 'auto';
|
|
35
112
|
}
|
|
113
|
+
function serializeInspectorValue(value) {
|
|
114
|
+
const encoded = JSON.stringify(value, (_key, nextValue) => {
|
|
115
|
+
if (nextValue instanceof Error) {
|
|
116
|
+
return {
|
|
117
|
+
name: nextValue.name,
|
|
118
|
+
message: nextValue.message,
|
|
119
|
+
stack: nextValue.stack,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (typeof nextValue === 'bigint') {
|
|
123
|
+
return nextValue.toString();
|
|
124
|
+
}
|
|
125
|
+
return nextValue;
|
|
126
|
+
});
|
|
127
|
+
if (!encoded)
|
|
128
|
+
return null;
|
|
129
|
+
return JSON.parse(encoded);
|
|
130
|
+
}
|
|
131
|
+
function serializeInspectorRecord(value) {
|
|
132
|
+
const serialized = serializeInspectorValue(value);
|
|
133
|
+
if (isRecord(serialized)) {
|
|
134
|
+
return serialized;
|
|
135
|
+
}
|
|
136
|
+
return { value: serialized };
|
|
137
|
+
}
|
|
36
138
|
export class SyncEngine {
|
|
37
139
|
config;
|
|
38
140
|
state;
|
|
@@ -48,6 +150,17 @@ export class SyncEngine {
|
|
|
48
150
|
retryTimeoutId = null;
|
|
49
151
|
realtimeCatchupTimeoutId = null;
|
|
50
152
|
hasRealtimeConnectedOnce = false;
|
|
153
|
+
transportHealth = {
|
|
154
|
+
mode: 'disconnected',
|
|
155
|
+
connected: false,
|
|
156
|
+
lastSuccessfulPollAt: null,
|
|
157
|
+
lastRealtimeMessageAt: null,
|
|
158
|
+
fallbackReason: null,
|
|
159
|
+
};
|
|
160
|
+
activeBootstrapSubscriptions = new Set();
|
|
161
|
+
bootstrapStartedAt = new Map();
|
|
162
|
+
inspectorEvents = [];
|
|
163
|
+
nextInspectorEventId = 1;
|
|
51
164
|
/**
|
|
52
165
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
53
166
|
* Used for efficient fingerprint-based rerender optimization.
|
|
@@ -69,6 +182,13 @@ export class SyncEngine {
|
|
|
69
182
|
this.config = config;
|
|
70
183
|
this.listeners = new Map();
|
|
71
184
|
this.state = this.createInitialState();
|
|
185
|
+
this.transportHealth = {
|
|
186
|
+
mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
|
|
187
|
+
connected: false,
|
|
188
|
+
lastSuccessfulPollAt: null,
|
|
189
|
+
lastRealtimeMessageAt: null,
|
|
190
|
+
fallbackReason: null,
|
|
191
|
+
};
|
|
72
192
|
}
|
|
73
193
|
/**
|
|
74
194
|
* Get mutation timestamp for a row (used by query hooks for fingerprinting).
|
|
@@ -212,6 +332,140 @@ export class SyncEngine {
|
|
|
212
332
|
getState() {
|
|
213
333
|
return this.state;
|
|
214
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Get transport health details (realtime/polling/fallback).
|
|
337
|
+
*/
|
|
338
|
+
getTransportHealth() {
|
|
339
|
+
return this.transportHealth;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Get subscription state metadata for the current profile.
|
|
343
|
+
*/
|
|
344
|
+
async listSubscriptionStates(args) {
|
|
345
|
+
return readSubscriptionStates(this.config.db, {
|
|
346
|
+
stateId: args?.stateId ?? this.getStateId(),
|
|
347
|
+
table: args?.table,
|
|
348
|
+
status: args?.status,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Get a single subscription state by id.
|
|
353
|
+
*/
|
|
354
|
+
async getSubscriptionState(subscriptionId, options) {
|
|
355
|
+
return readSubscriptionState(this.config.db, {
|
|
356
|
+
stateId: options?.stateId ?? this.getStateId(),
|
|
357
|
+
subscriptionId,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Get normalized progress for all active subscriptions in this state profile.
|
|
362
|
+
*/
|
|
363
|
+
async getProgress() {
|
|
364
|
+
const subscriptions = await this.listSubscriptionStates();
|
|
365
|
+
const progress = subscriptions.map((sub) => this.mapSubscriptionToProgress(sub));
|
|
366
|
+
const channelPhase = this.resolveChannelPhase(progress);
|
|
367
|
+
const hasSubscriptions = progress.length > 0;
|
|
368
|
+
const basePercent = hasSubscriptions
|
|
369
|
+
? Math.round(progress.reduce((sum, item) => sum + item.progressPercent, 0) /
|
|
370
|
+
progress.length)
|
|
371
|
+
: this.state.lastSyncAt !== null
|
|
372
|
+
? 100
|
|
373
|
+
: 0;
|
|
374
|
+
const progressPercent = channelPhase === 'live'
|
|
375
|
+
? 100
|
|
376
|
+
: Math.max(0, Math.min(100, Math.trunc(basePercent)));
|
|
377
|
+
return {
|
|
378
|
+
channelPhase,
|
|
379
|
+
progressPercent,
|
|
380
|
+
subscriptions: progress,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Wait until the channel reaches a target phase.
|
|
385
|
+
*/
|
|
386
|
+
async awaitPhase(phase, options = {}) {
|
|
387
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
|
|
388
|
+
const deadline = Date.now() + timeoutMs;
|
|
389
|
+
while (true) {
|
|
390
|
+
const progress = await this.getProgress();
|
|
391
|
+
if (progress.channelPhase === phase) {
|
|
392
|
+
return progress;
|
|
393
|
+
}
|
|
394
|
+
if (progress.channelPhase === 'error') {
|
|
395
|
+
const message = this.state.error?.message ?? 'Sync entered error state';
|
|
396
|
+
throw new Error(`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`);
|
|
397
|
+
}
|
|
398
|
+
const remainingMs = deadline - Date.now();
|
|
399
|
+
if (remainingMs <= 0) {
|
|
400
|
+
throw new Error(`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`);
|
|
401
|
+
}
|
|
402
|
+
await this.waitForProgressSignal(remainingMs);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Wait until bootstrap finishes for a state or a specific subscription.
|
|
407
|
+
*/
|
|
408
|
+
async awaitBootstrapComplete(options = {}) {
|
|
409
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
|
|
410
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
411
|
+
const deadline = Date.now() + timeoutMs;
|
|
412
|
+
while (true) {
|
|
413
|
+
const states = await this.listSubscriptionStates({ stateId });
|
|
414
|
+
const relevantStates = options.subscriptionId === undefined
|
|
415
|
+
? states
|
|
416
|
+
: states.filter((state) => state.subscriptionId === options.subscriptionId);
|
|
417
|
+
const hasPendingBootstrap = relevantStates.some((state) => state.status === 'active' && state.bootstrapState !== null);
|
|
418
|
+
if (!hasPendingBootstrap) {
|
|
419
|
+
return this.getProgress();
|
|
420
|
+
}
|
|
421
|
+
if (this.state.error) {
|
|
422
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`);
|
|
423
|
+
}
|
|
424
|
+
const remainingMs = deadline - Date.now();
|
|
425
|
+
if (remainingMs <= 0) {
|
|
426
|
+
const target = options.subscriptionId === undefined
|
|
427
|
+
? `state "${stateId}"`
|
|
428
|
+
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
429
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`);
|
|
430
|
+
}
|
|
431
|
+
await this.waitForProgressSignal(remainingMs);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Get a diagnostics snapshot suitable for debug UIs and bug reports.
|
|
436
|
+
*/
|
|
437
|
+
async getDiagnostics() {
|
|
438
|
+
const [subscriptions, progress, outbox, conflicts] = await Promise.all([
|
|
439
|
+
this.listSubscriptionStates(),
|
|
440
|
+
this.getProgress(),
|
|
441
|
+
this.refreshOutboxStats({ emit: false }),
|
|
442
|
+
this.getConflicts(),
|
|
443
|
+
]);
|
|
444
|
+
return {
|
|
445
|
+
timestamp: Date.now(),
|
|
446
|
+
state: this.state,
|
|
447
|
+
transport: this.transportHealth,
|
|
448
|
+
progress,
|
|
449
|
+
outbox,
|
|
450
|
+
conflictCount: conflicts.length,
|
|
451
|
+
subscriptions,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Get a serializable inspector snapshot for app debug UIs and support tooling.
|
|
456
|
+
*/
|
|
457
|
+
async getInspectorSnapshot(options = {}) {
|
|
458
|
+
const diagnostics = await this.getDiagnostics();
|
|
459
|
+
const requestedLimit = options.eventLimit ?? DEFAULT_INSPECTOR_EVENT_LIMIT;
|
|
460
|
+
const eventLimit = Math.max(0, Math.min(MAX_INSPECTOR_EVENT_LIMIT, requestedLimit));
|
|
461
|
+
const recentEvents = eventLimit === 0 ? [] : this.inspectorEvents.slice(-eventLimit);
|
|
462
|
+
return {
|
|
463
|
+
version: 1,
|
|
464
|
+
generatedAt: Date.now(),
|
|
465
|
+
diagnostics: serializeInspectorRecord(diagnostics),
|
|
466
|
+
recentEvents,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
215
469
|
/**
|
|
216
470
|
* Get database instance
|
|
217
471
|
*/
|
|
@@ -230,6 +484,366 @@ export class SyncEngine {
|
|
|
230
484
|
getClientId() {
|
|
231
485
|
return this.config.clientId;
|
|
232
486
|
}
|
|
487
|
+
getStateId() {
|
|
488
|
+
return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
489
|
+
}
|
|
490
|
+
makeBootstrapKey(stateId, subscriptionId) {
|
|
491
|
+
return `${stateId}:${subscriptionId}`;
|
|
492
|
+
}
|
|
493
|
+
updateTransportHealth(partial) {
|
|
494
|
+
this.transportHealth = {
|
|
495
|
+
...this.transportHealth,
|
|
496
|
+
...partial,
|
|
497
|
+
};
|
|
498
|
+
this.emit('state:change', {});
|
|
499
|
+
}
|
|
500
|
+
waitForProgressSignal(timeoutMs) {
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
const cleanups = [];
|
|
503
|
+
let settled = false;
|
|
504
|
+
const finish = () => {
|
|
505
|
+
if (settled)
|
|
506
|
+
return;
|
|
507
|
+
settled = true;
|
|
508
|
+
clearTimeout(timeoutId);
|
|
509
|
+
for (const cleanup of cleanups)
|
|
510
|
+
cleanup();
|
|
511
|
+
resolve();
|
|
512
|
+
};
|
|
513
|
+
const listen = (event) => {
|
|
514
|
+
cleanups.push(this.on(event, finish));
|
|
515
|
+
};
|
|
516
|
+
listen('sync:start');
|
|
517
|
+
listen('sync:complete');
|
|
518
|
+
listen('sync:error');
|
|
519
|
+
listen('sync:live');
|
|
520
|
+
listen('bootstrap:start');
|
|
521
|
+
listen('bootstrap:progress');
|
|
522
|
+
listen('bootstrap:complete');
|
|
523
|
+
const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
mapSubscriptionToProgress(subscription) {
|
|
527
|
+
if (subscription.status === 'revoked') {
|
|
528
|
+
return {
|
|
529
|
+
stateId: subscription.stateId,
|
|
530
|
+
id: subscription.subscriptionId,
|
|
531
|
+
table: subscription.table,
|
|
532
|
+
phase: 'error',
|
|
533
|
+
progressPercent: 0,
|
|
534
|
+
startedAt: subscription.createdAt,
|
|
535
|
+
completedAt: subscription.updatedAt,
|
|
536
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
537
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (subscription.bootstrapState) {
|
|
541
|
+
const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
|
|
542
|
+
const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
|
|
543
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
544
|
+
const progressPercent = tableCount === 0
|
|
545
|
+
? 0
|
|
546
|
+
: Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
|
|
547
|
+
return {
|
|
548
|
+
stateId: subscription.stateId,
|
|
549
|
+
id: subscription.subscriptionId,
|
|
550
|
+
table: subscription.table,
|
|
551
|
+
phase: 'bootstrapping',
|
|
552
|
+
progressPercent,
|
|
553
|
+
tablesProcessed,
|
|
554
|
+
tablesTotal: tableCount,
|
|
555
|
+
startedAt: this.bootstrapStartedAt.get(this.makeBootstrapKey(subscription.stateId, subscription.subscriptionId)),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (this.state.error) {
|
|
559
|
+
return {
|
|
560
|
+
stateId: subscription.stateId,
|
|
561
|
+
id: subscription.subscriptionId,
|
|
562
|
+
table: subscription.table,
|
|
563
|
+
phase: 'error',
|
|
564
|
+
progressPercent: subscription.cursor >= 0 ? 100 : 0,
|
|
565
|
+
startedAt: subscription.createdAt,
|
|
566
|
+
lastErrorCode: this.state.error.code,
|
|
567
|
+
lastErrorMessage: this.state.error.message,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (this.state.isSyncing) {
|
|
571
|
+
return {
|
|
572
|
+
stateId: subscription.stateId,
|
|
573
|
+
id: subscription.subscriptionId,
|
|
574
|
+
table: subscription.table,
|
|
575
|
+
phase: 'catching_up',
|
|
576
|
+
progressPercent: subscription.cursor >= 0 ? 90 : 0,
|
|
577
|
+
startedAt: subscription.createdAt,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
|
|
581
|
+
return {
|
|
582
|
+
stateId: subscription.stateId,
|
|
583
|
+
id: subscription.subscriptionId,
|
|
584
|
+
table: subscription.table,
|
|
585
|
+
phase: 'live',
|
|
586
|
+
progressPercent: 100,
|
|
587
|
+
startedAt: subscription.createdAt,
|
|
588
|
+
completedAt: subscription.updatedAt,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
stateId: subscription.stateId,
|
|
593
|
+
id: subscription.subscriptionId,
|
|
594
|
+
table: subscription.table,
|
|
595
|
+
phase: 'idle',
|
|
596
|
+
progressPercent: 0,
|
|
597
|
+
startedAt: subscription.createdAt,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
resolveChannelPhase(subscriptions) {
|
|
601
|
+
if (this.state.error)
|
|
602
|
+
return 'error';
|
|
603
|
+
if (subscriptions.some((sub) => sub.phase === 'error'))
|
|
604
|
+
return 'error';
|
|
605
|
+
if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
|
|
606
|
+
return 'bootstrapping';
|
|
607
|
+
}
|
|
608
|
+
if (this.state.isSyncing) {
|
|
609
|
+
return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
|
|
610
|
+
}
|
|
611
|
+
if (this.state.lastSyncAt !== null)
|
|
612
|
+
return 'live';
|
|
613
|
+
return 'idle';
|
|
614
|
+
}
|
|
615
|
+
deriveProgressFromPullSubscription(sub) {
|
|
616
|
+
const stateId = this.getStateId();
|
|
617
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
618
|
+
const startedAt = this.bootstrapStartedAt.get(key);
|
|
619
|
+
if (sub.status === 'revoked') {
|
|
620
|
+
return {
|
|
621
|
+
stateId,
|
|
622
|
+
id: sub.id,
|
|
623
|
+
phase: 'error',
|
|
624
|
+
progressPercent: 0,
|
|
625
|
+
startedAt,
|
|
626
|
+
completedAt: Date.now(),
|
|
627
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
628
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (sub.bootstrap && sub.bootstrapState) {
|
|
632
|
+
const tableCount = Math.max(0, sub.bootstrapState.tables.length);
|
|
633
|
+
const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
|
|
634
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
635
|
+
const progressPercent = tableCount === 0
|
|
636
|
+
? 0
|
|
637
|
+
: Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
|
|
638
|
+
return {
|
|
639
|
+
stateId,
|
|
640
|
+
id: sub.id,
|
|
641
|
+
phase: 'bootstrapping',
|
|
642
|
+
progressPercent,
|
|
643
|
+
tablesProcessed,
|
|
644
|
+
tablesTotal: tableCount,
|
|
645
|
+
startedAt,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
stateId,
|
|
650
|
+
id: sub.id,
|
|
651
|
+
phase: this.state.isSyncing ? 'catching_up' : 'live',
|
|
652
|
+
progressPercent: this.state.isSyncing ? 90 : 100,
|
|
653
|
+
startedAt,
|
|
654
|
+
completedAt: this.state.isSyncing ? undefined : Date.now(),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
handleBootstrapLifecycle(response) {
|
|
658
|
+
const stateId = this.getStateId();
|
|
659
|
+
const now = Date.now();
|
|
660
|
+
const seenKeys = new Set();
|
|
661
|
+
for (const sub of response.subscriptions ?? []) {
|
|
662
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
663
|
+
seenKeys.add(key);
|
|
664
|
+
const isBootstrapping = sub.bootstrap === true;
|
|
665
|
+
const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
|
|
666
|
+
if (isBootstrapping && !wasBootstrapping) {
|
|
667
|
+
this.activeBootstrapSubscriptions.add(key);
|
|
668
|
+
this.bootstrapStartedAt.set(key, now);
|
|
669
|
+
this.emit('bootstrap:start', {
|
|
670
|
+
timestamp: now,
|
|
671
|
+
stateId,
|
|
672
|
+
subscriptionId: sub.id,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (isBootstrapping) {
|
|
676
|
+
this.emit('bootstrap:progress', {
|
|
677
|
+
timestamp: now,
|
|
678
|
+
stateId,
|
|
679
|
+
subscriptionId: sub.id,
|
|
680
|
+
progress: this.deriveProgressFromPullSubscription(sub),
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
if (!isBootstrapping && wasBootstrapping) {
|
|
684
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
685
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
686
|
+
this.bootstrapStartedAt.delete(key);
|
|
687
|
+
this.emit('bootstrap:complete', {
|
|
688
|
+
timestamp: now,
|
|
689
|
+
stateId,
|
|
690
|
+
subscriptionId: sub.id,
|
|
691
|
+
durationMs: Math.max(0, now - startedAt),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const key of Array.from(this.activeBootstrapSubscriptions)) {
|
|
696
|
+
if (seenKeys.has(key))
|
|
697
|
+
continue;
|
|
698
|
+
if (!key.startsWith(`${stateId}:`))
|
|
699
|
+
continue;
|
|
700
|
+
const subscriptionId = key.slice(stateId.length + 1);
|
|
701
|
+
if (!subscriptionId)
|
|
702
|
+
continue;
|
|
703
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
704
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
705
|
+
this.bootstrapStartedAt.delete(key);
|
|
706
|
+
this.emit('bootstrap:complete', {
|
|
707
|
+
timestamp: now,
|
|
708
|
+
stateId,
|
|
709
|
+
subscriptionId,
|
|
710
|
+
durationMs: Math.max(0, now - startedAt),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
|
|
714
|
+
this.emit('sync:live', { timestamp: now });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async resolveResetTargets(options) {
|
|
718
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
719
|
+
if (options.scope === 'all') {
|
|
720
|
+
return readSubscriptionStates(this.config.db);
|
|
721
|
+
}
|
|
722
|
+
if (options.scope === 'state') {
|
|
723
|
+
return readSubscriptionStates(this.config.db, { stateId });
|
|
724
|
+
}
|
|
725
|
+
const subscriptionIds = options.subscriptionIds ?? [];
|
|
726
|
+
if (subscriptionIds.length === 0) {
|
|
727
|
+
throw new Error('[SyncEngine.reset] subscriptionIds is required when scope="subscription"');
|
|
728
|
+
}
|
|
729
|
+
const allInState = await readSubscriptionStates(this.config.db, {
|
|
730
|
+
stateId,
|
|
731
|
+
});
|
|
732
|
+
const wanted = new Set(subscriptionIds);
|
|
733
|
+
return allInState.filter((state) => wanted.has(state.subscriptionId));
|
|
734
|
+
}
|
|
735
|
+
async clearSyncedTablesForReset(trx, options, targets) {
|
|
736
|
+
const clearedTables = [];
|
|
737
|
+
if (!options.clearSyncedTables) {
|
|
738
|
+
return clearedTables;
|
|
739
|
+
}
|
|
740
|
+
if (options.scope === 'all') {
|
|
741
|
+
for (const handler of this.config.handlers.getAll()) {
|
|
742
|
+
await handler.clearAll({ trx, scopes: {} });
|
|
743
|
+
clearedTables.push(handler.table);
|
|
744
|
+
}
|
|
745
|
+
return clearedTables;
|
|
746
|
+
}
|
|
747
|
+
const seen = new Set();
|
|
748
|
+
for (const target of targets) {
|
|
749
|
+
const handler = this.config.handlers.get(target.table);
|
|
750
|
+
if (!handler)
|
|
751
|
+
continue;
|
|
752
|
+
const key = `${target.table}:${JSON.stringify(target.scopes)}`;
|
|
753
|
+
if (seen.has(key))
|
|
754
|
+
continue;
|
|
755
|
+
seen.add(key);
|
|
756
|
+
await handler.clearAll({ trx, scopes: target.scopes });
|
|
757
|
+
clearedTables.push(target.table);
|
|
758
|
+
}
|
|
759
|
+
return clearedTables;
|
|
760
|
+
}
|
|
761
|
+
async reset(options) {
|
|
762
|
+
const resetOptions = {
|
|
763
|
+
clearOutbox: false,
|
|
764
|
+
clearConflicts: false,
|
|
765
|
+
clearSyncedTables: false,
|
|
766
|
+
...options,
|
|
767
|
+
};
|
|
768
|
+
const targets = await this.resolveResetTargets(resetOptions);
|
|
769
|
+
const stateId = resetOptions.stateId ?? this.getStateId();
|
|
770
|
+
this.stop();
|
|
771
|
+
const result = await this.config.db.transaction().execute(async (trx) => {
|
|
772
|
+
const clearedTables = await this.clearSyncedTablesForReset(trx, resetOptions, targets);
|
|
773
|
+
let deletedSubscriptionStates = 0;
|
|
774
|
+
if (resetOptions.scope === 'all') {
|
|
775
|
+
const res = await sql `
|
|
776
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
777
|
+
`.execute(trx);
|
|
778
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
779
|
+
}
|
|
780
|
+
else if (resetOptions.scope === 'state') {
|
|
781
|
+
const res = await sql `
|
|
782
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
783
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
784
|
+
`.execute(trx);
|
|
785
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
const subscriptionIds = resetOptions.subscriptionIds ?? [];
|
|
789
|
+
const res = await sql `
|
|
790
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
791
|
+
where
|
|
792
|
+
${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
793
|
+
and ${sql.ref('subscription_id')} in (${sql.join(subscriptionIds.map((id) => sql.val(id)))})
|
|
794
|
+
`.execute(trx);
|
|
795
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
796
|
+
}
|
|
797
|
+
let deletedOutboxCommits = 0;
|
|
798
|
+
if (resetOptions.clearOutbox) {
|
|
799
|
+
const res = await sql `
|
|
800
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
801
|
+
`.execute(trx);
|
|
802
|
+
deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
|
|
803
|
+
}
|
|
804
|
+
let deletedConflicts = 0;
|
|
805
|
+
if (resetOptions.clearConflicts) {
|
|
806
|
+
const res = await sql `
|
|
807
|
+
delete from ${sql.table('sync_conflicts')}
|
|
808
|
+
`.execute(trx);
|
|
809
|
+
deletedConflicts = Number(res.numAffectedRows ?? 0);
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
deletedSubscriptionStates,
|
|
813
|
+
deletedOutboxCommits,
|
|
814
|
+
deletedConflicts,
|
|
815
|
+
clearedTables,
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
if (resetOptions.scope === 'all') {
|
|
819
|
+
this.activeBootstrapSubscriptions.clear();
|
|
820
|
+
this.bootstrapStartedAt.clear();
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
for (const target of targets) {
|
|
824
|
+
const key = this.makeBootstrapKey(target.stateId, target.subscriptionId);
|
|
825
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
826
|
+
this.bootstrapStartedAt.delete(key);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
this.resetLocalState();
|
|
830
|
+
await this.refreshOutboxStats();
|
|
831
|
+
this.updateState({ error: null });
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
async repair(options) {
|
|
835
|
+
if (options.mode !== 'rebootstrap-missing-chunks') {
|
|
836
|
+
throw new Error(`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`);
|
|
837
|
+
}
|
|
838
|
+
return this.reset({
|
|
839
|
+
scope: options.subscriptionIds ? 'subscription' : 'state',
|
|
840
|
+
stateId: options.stateId,
|
|
841
|
+
subscriptionIds: options.subscriptionIds,
|
|
842
|
+
clearOutbox: options.clearOutbox ?? false,
|
|
843
|
+
clearConflicts: options.clearConflicts ?? false,
|
|
844
|
+
clearSyncedTables: true,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
233
847
|
/**
|
|
234
848
|
* Subscribe to sync events
|
|
235
849
|
*/
|
|
@@ -253,6 +867,15 @@ export class SyncEngine {
|
|
|
253
867
|
return this.on('state:change', callback);
|
|
254
868
|
}
|
|
255
869
|
emit(event, payload) {
|
|
870
|
+
this.inspectorEvents.push({
|
|
871
|
+
id: this.nextInspectorEventId++,
|
|
872
|
+
event,
|
|
873
|
+
timestamp: Date.now(),
|
|
874
|
+
payload: serializeInspectorRecord(payload),
|
|
875
|
+
});
|
|
876
|
+
if (this.inspectorEvents.length > MAX_INSPECTOR_EVENT_LIMIT) {
|
|
877
|
+
this.inspectorEvents.splice(0, this.inspectorEvents.length - MAX_INSPECTOR_EVENT_LIMIT);
|
|
878
|
+
}
|
|
256
879
|
const eventListeners = this.listeners.get(event);
|
|
257
880
|
if (eventListeners) {
|
|
258
881
|
for (const listener of eventListeners) {
|
|
@@ -323,7 +946,17 @@ export class SyncEngine {
|
|
|
323
946
|
catch (err) {
|
|
324
947
|
const migrationError = err instanceof Error ? err : new Error(String(err));
|
|
325
948
|
this.config.onMigrationError?.(migrationError);
|
|
326
|
-
const error = createSyncError(
|
|
949
|
+
const error = createSyncError({
|
|
950
|
+
code: 'MIGRATION_FAILED',
|
|
951
|
+
message: 'Migration failed',
|
|
952
|
+
cause: migrationError,
|
|
953
|
+
retryable: false,
|
|
954
|
+
stateId: this.getStateId(),
|
|
955
|
+
});
|
|
956
|
+
this.updateState({
|
|
957
|
+
isSyncing: false,
|
|
958
|
+
error,
|
|
959
|
+
});
|
|
327
960
|
this.handleError(error);
|
|
328
961
|
return;
|
|
329
962
|
}
|
|
@@ -381,7 +1014,12 @@ export class SyncEngine {
|
|
|
381
1014
|
pushedCommits: 0,
|
|
382
1015
|
pullRounds: 0,
|
|
383
1016
|
pullResponse: { ok: true, subscriptions: [] },
|
|
384
|
-
error: createSyncError(
|
|
1017
|
+
error: createSyncError({
|
|
1018
|
+
code: 'SYNC_ERROR',
|
|
1019
|
+
message: 'Sync not enabled',
|
|
1020
|
+
retryable: false,
|
|
1021
|
+
stateId: this.getStateId(),
|
|
1022
|
+
}),
|
|
385
1023
|
};
|
|
386
1024
|
}
|
|
387
1025
|
this.syncPromise = this.performSyncLoop(opts?.trigger);
|
|
@@ -398,7 +1036,12 @@ export class SyncEngine {
|
|
|
398
1036
|
pushedCommits: 0,
|
|
399
1037
|
pullRounds: 0,
|
|
400
1038
|
pullResponse: { ok: true, subscriptions: [] },
|
|
401
|
-
error: createSyncError(
|
|
1039
|
+
error: createSyncError({
|
|
1040
|
+
code: 'SYNC_ERROR',
|
|
1041
|
+
message: 'Sync not started',
|
|
1042
|
+
retryable: false,
|
|
1043
|
+
stateId: this.getStateId(),
|
|
1044
|
+
}),
|
|
402
1045
|
};
|
|
403
1046
|
do {
|
|
404
1047
|
this.syncRequestedWhileRunning = false;
|
|
@@ -457,6 +1100,9 @@ export class SyncEngine {
|
|
|
457
1100
|
retryCount: 0,
|
|
458
1101
|
isRetrying: false,
|
|
459
1102
|
});
|
|
1103
|
+
this.updateTransportHealth({
|
|
1104
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1105
|
+
});
|
|
460
1106
|
this.emit('sync:complete', {
|
|
461
1107
|
timestamp: Date.now(),
|
|
462
1108
|
pushedCommits: result.pushedCommits,
|
|
@@ -472,6 +1118,7 @@ export class SyncEngine {
|
|
|
472
1118
|
});
|
|
473
1119
|
this.config.onDataChange?.(changedTables);
|
|
474
1120
|
}
|
|
1121
|
+
this.handleBootstrapLifecycle(result.pullResponse);
|
|
475
1122
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
476
1123
|
this.refreshOutboxStats().catch((error) => {
|
|
477
1124
|
console.warn('[SyncEngine] Failed to refresh outbox stats after sync:', error);
|
|
@@ -499,7 +1146,15 @@ export class SyncEngine {
|
|
|
499
1146
|
return syncResult;
|
|
500
1147
|
}
|
|
501
1148
|
catch (err) {
|
|
502
|
-
const
|
|
1149
|
+
const classified = classifySyncFailure(err);
|
|
1150
|
+
const error = createSyncError({
|
|
1151
|
+
code: classified.code,
|
|
1152
|
+
message: classified.message,
|
|
1153
|
+
cause: classified.cause,
|
|
1154
|
+
retryable: classified.retryable,
|
|
1155
|
+
httpStatus: classified.httpStatus,
|
|
1156
|
+
stateId: this.getStateId(),
|
|
1157
|
+
});
|
|
503
1158
|
this.updateState({
|
|
504
1159
|
isSyncing: false,
|
|
505
1160
|
error,
|
|
@@ -527,7 +1182,7 @@ export class SyncEngine {
|
|
|
527
1182
|
});
|
|
528
1183
|
// Schedule retry if under max retries
|
|
529
1184
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
530
|
-
if (this.state.retryCount < maxRetries) {
|
|
1185
|
+
if (error.retryable && this.state.retryCount < maxRetries) {
|
|
531
1186
|
this.scheduleRetry();
|
|
532
1187
|
}
|
|
533
1188
|
return {
|
|
@@ -666,12 +1321,19 @@ export class SyncEngine {
|
|
|
666
1321
|
retryCount: 0,
|
|
667
1322
|
isRetrying: false,
|
|
668
1323
|
});
|
|
1324
|
+
this.updateTransportHealth({
|
|
1325
|
+
mode: 'realtime',
|
|
1326
|
+
connected: true,
|
|
1327
|
+
fallbackReason: null,
|
|
1328
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1329
|
+
});
|
|
669
1330
|
this.emit('sync:complete', {
|
|
670
1331
|
timestamp: Date.now(),
|
|
671
1332
|
pushedCommits: 0,
|
|
672
1333
|
pullRounds: 0,
|
|
673
1334
|
pullResponse: { ok: true, subscriptions: [] },
|
|
674
1335
|
});
|
|
1336
|
+
this.emit('sync:live', { timestamp: Date.now() });
|
|
675
1337
|
this.refreshOutboxStats().catch((error) => {
|
|
676
1338
|
console.warn('[SyncEngine] Failed to refresh outbox stats after WS apply:', error);
|
|
677
1339
|
});
|
|
@@ -780,6 +1442,11 @@ export class SyncEngine {
|
|
|
780
1442
|
}
|
|
781
1443
|
}, interval);
|
|
782
1444
|
this.setConnectionState('connected');
|
|
1445
|
+
this.updateTransportHealth({
|
|
1446
|
+
mode: 'polling',
|
|
1447
|
+
connected: true,
|
|
1448
|
+
fallbackReason: null,
|
|
1449
|
+
});
|
|
783
1450
|
}
|
|
784
1451
|
stopPolling() {
|
|
785
1452
|
if (this.pollerId) {
|
|
@@ -795,6 +1462,11 @@ export class SyncEngine {
|
|
|
795
1462
|
return;
|
|
796
1463
|
}
|
|
797
1464
|
this.setConnectionState('connecting');
|
|
1465
|
+
this.updateTransportHealth({
|
|
1466
|
+
mode: 'disconnected',
|
|
1467
|
+
connected: false,
|
|
1468
|
+
fallbackReason: null,
|
|
1469
|
+
});
|
|
798
1470
|
const transport = this.config.transport;
|
|
799
1471
|
// Wire up presence events if transport supports them
|
|
800
1472
|
if (transport.onPresenceEvent) {
|
|
@@ -817,6 +1489,9 @@ export class SyncEngine {
|
|
|
817
1489
|
}
|
|
818
1490
|
this.realtimeDisconnect = transport.connect({ clientId: this.config.clientId }, (event) => {
|
|
819
1491
|
if (event.event === 'sync') {
|
|
1492
|
+
this.updateTransportHealth({
|
|
1493
|
+
lastRealtimeMessageAt: Date.now(),
|
|
1494
|
+
});
|
|
820
1495
|
countSyncMetric('sync.client.ws.events', 1, {
|
|
821
1496
|
attributes: { type: 'sync' },
|
|
822
1497
|
});
|
|
@@ -840,6 +1515,11 @@ export class SyncEngine {
|
|
|
840
1515
|
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
841
1516
|
this.hasRealtimeConnectedOnce = true;
|
|
842
1517
|
this.setConnectionState('connected');
|
|
1518
|
+
this.updateTransportHealth({
|
|
1519
|
+
mode: 'realtime',
|
|
1520
|
+
connected: true,
|
|
1521
|
+
fallbackReason: null,
|
|
1522
|
+
});
|
|
843
1523
|
this.stopFallbackPolling();
|
|
844
1524
|
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
845
1525
|
if (wasConnectedBefore) {
|
|
@@ -849,9 +1529,17 @@ export class SyncEngine {
|
|
|
849
1529
|
}
|
|
850
1530
|
case 'connecting':
|
|
851
1531
|
this.setConnectionState('connecting');
|
|
1532
|
+
this.updateTransportHealth({
|
|
1533
|
+
mode: 'disconnected',
|
|
1534
|
+
connected: false,
|
|
1535
|
+
});
|
|
852
1536
|
break;
|
|
853
1537
|
case 'disconnected':
|
|
854
1538
|
this.setConnectionState('reconnecting');
|
|
1539
|
+
this.updateTransportHealth({
|
|
1540
|
+
mode: 'disconnected',
|
|
1541
|
+
connected: false,
|
|
1542
|
+
});
|
|
855
1543
|
this.startFallbackPolling();
|
|
856
1544
|
break;
|
|
857
1545
|
}
|
|
@@ -871,6 +1559,10 @@ export class SyncEngine {
|
|
|
871
1559
|
this.realtimeDisconnect = null;
|
|
872
1560
|
}
|
|
873
1561
|
this.stopFallbackPolling();
|
|
1562
|
+
this.updateTransportHealth({
|
|
1563
|
+
mode: 'disconnected',
|
|
1564
|
+
connected: false,
|
|
1565
|
+
});
|
|
874
1566
|
}
|
|
875
1567
|
scheduleRealtimeReconnectCatchupSync() {
|
|
876
1568
|
if (this.realtimeCatchupTimeoutId) {
|
|
@@ -889,6 +1581,11 @@ export class SyncEngine {
|
|
|
889
1581
|
if (this.fallbackPollerId)
|
|
890
1582
|
return;
|
|
891
1583
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
1584
|
+
this.updateTransportHealth({
|
|
1585
|
+
mode: 'polling',
|
|
1586
|
+
connected: false,
|
|
1587
|
+
fallbackReason: 'network',
|
|
1588
|
+
});
|
|
892
1589
|
this.fallbackPollerId = setInterval(() => {
|
|
893
1590
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
894
1591
|
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
@@ -900,6 +1597,7 @@ export class SyncEngine {
|
|
|
900
1597
|
clearInterval(this.fallbackPollerId);
|
|
901
1598
|
this.fallbackPollerId = null;
|
|
902
1599
|
}
|
|
1600
|
+
this.updateTransportHealth({ fallbackReason: null });
|
|
903
1601
|
}
|
|
904
1602
|
/**
|
|
905
1603
|
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|