@syncular/client 0.0.2-2 → 0.0.3-6
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 +65 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +96 -0
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts +51 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +654 -9
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +100 -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/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.ts +146 -0
- package/src/engine/SyncEngine.ts +836 -21
- package/src/engine/types.ts +132 -1
- package/src/index.ts +1 -0
- package/src/subscription-state.ts +259 -0
- package/src/utils/id.ts +35 -0
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -12,31 +12,48 @@ import {
|
|
|
12
12
|
isRecord,
|
|
13
13
|
type SyncChange,
|
|
14
14
|
type SyncPullResponse,
|
|
15
|
+
type SyncPullSubscriptionResponse,
|
|
15
16
|
type SyncSubscriptionRequest,
|
|
17
|
+
SyncTransportError,
|
|
16
18
|
startSyncSpan,
|
|
17
19
|
} from '@syncular/core';
|
|
18
|
-
import { type Kysely, sql } from 'kysely';
|
|
20
|
+
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
19
21
|
import { syncPushOnce } from '../push-engine';
|
|
20
22
|
import type {
|
|
21
23
|
ConflictResultStatus,
|
|
22
24
|
OutboxCommitStatus,
|
|
23
25
|
SyncClientDb,
|
|
24
26
|
} from '../schema';
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_SYNC_STATE_ID,
|
|
29
|
+
getSubscriptionState as readSubscriptionState,
|
|
30
|
+
listSubscriptionStates as readSubscriptionStates,
|
|
31
|
+
type SubscriptionState,
|
|
32
|
+
} from '../subscription-state';
|
|
25
33
|
import { syncOnce } from '../sync-loop';
|
|
26
34
|
import type {
|
|
27
35
|
ConflictInfo,
|
|
28
36
|
OutboxStats,
|
|
29
37
|
PresenceEntry,
|
|
30
38
|
RealtimeTransportLike,
|
|
39
|
+
SubscriptionProgress,
|
|
40
|
+
SyncAwaitBootstrapOptions,
|
|
41
|
+
SyncAwaitPhaseOptions,
|
|
31
42
|
SyncConnectionState,
|
|
43
|
+
SyncDiagnostics,
|
|
32
44
|
SyncEngineConfig,
|
|
33
45
|
SyncEngineState,
|
|
34
46
|
SyncError,
|
|
35
47
|
SyncEventListener,
|
|
36
48
|
SyncEventPayloads,
|
|
37
49
|
SyncEventType,
|
|
50
|
+
SyncProgress,
|
|
51
|
+
SyncRepairOptions,
|
|
52
|
+
SyncResetOptions,
|
|
53
|
+
SyncResetResult,
|
|
38
54
|
SyncResult,
|
|
39
55
|
SyncTransportMode,
|
|
56
|
+
TransportHealth,
|
|
40
57
|
} from './types';
|
|
41
58
|
|
|
42
59
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
@@ -45,6 +62,7 @@ const INITIAL_RETRY_DELAY_MS = 1000;
|
|
|
45
62
|
const MAX_RETRY_DELAY_MS = 60000;
|
|
46
63
|
const EXPONENTIAL_FACTOR = 2;
|
|
47
64
|
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
65
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
|
|
48
66
|
|
|
49
67
|
function calculateRetryDelay(attemptIndex: number): number {
|
|
50
68
|
return Math.min(
|
|
@@ -63,16 +81,113 @@ function isRealtimeTransport(
|
|
|
63
81
|
);
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
function createSyncError(
|
|
67
|
-
code: SyncError['code']
|
|
68
|
-
message: string
|
|
69
|
-
cause?: Error
|
|
70
|
-
|
|
84
|
+
function createSyncError(args: {
|
|
85
|
+
code: SyncError['code'];
|
|
86
|
+
message: string;
|
|
87
|
+
cause?: Error;
|
|
88
|
+
retryable?: boolean;
|
|
89
|
+
httpStatus?: number;
|
|
90
|
+
subscriptionId?: string;
|
|
91
|
+
stateId?: string;
|
|
92
|
+
}): SyncError {
|
|
93
|
+
return {
|
|
94
|
+
code: args.code,
|
|
95
|
+
message: args.message,
|
|
96
|
+
cause: args.cause,
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
retryable: args.retryable ?? false,
|
|
99
|
+
httpStatus: args.httpStatus,
|
|
100
|
+
subscriptionId: args.subscriptionId,
|
|
101
|
+
stateId: args.stateId,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function classifySyncFailure(error: unknown): {
|
|
106
|
+
code: SyncError['code'];
|
|
107
|
+
message: string;
|
|
108
|
+
cause: Error;
|
|
109
|
+
retryable: boolean;
|
|
110
|
+
httpStatus?: number;
|
|
111
|
+
} {
|
|
112
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
113
|
+
const message = cause.message || 'Sync failed';
|
|
114
|
+
const normalized = message.toLowerCase();
|
|
115
|
+
|
|
116
|
+
if (cause instanceof SyncTransportError) {
|
|
117
|
+
if (cause.status === 401 || cause.status === 403) {
|
|
118
|
+
return {
|
|
119
|
+
code: 'AUTH_FAILED',
|
|
120
|
+
message,
|
|
121
|
+
cause,
|
|
122
|
+
retryable: false,
|
|
123
|
+
httpStatus: cause.status,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
cause.status === 404 &&
|
|
129
|
+
normalized.includes('snapshot') &&
|
|
130
|
+
normalized.includes('chunk')
|
|
131
|
+
) {
|
|
132
|
+
return {
|
|
133
|
+
code: 'SNAPSHOT_CHUNK_NOT_FOUND',
|
|
134
|
+
message,
|
|
135
|
+
cause,
|
|
136
|
+
retryable: false,
|
|
137
|
+
httpStatus: cause.status,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
cause.status !== undefined &&
|
|
143
|
+
(cause.status >= 500 || cause.status === 408 || cause.status === 429)
|
|
144
|
+
) {
|
|
145
|
+
return {
|
|
146
|
+
code: 'NETWORK_ERROR',
|
|
147
|
+
message,
|
|
148
|
+
cause,
|
|
149
|
+
retryable: true,
|
|
150
|
+
httpStatus: cause.status,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
code: 'SYNC_ERROR',
|
|
156
|
+
message,
|
|
157
|
+
cause,
|
|
158
|
+
retryable: false,
|
|
159
|
+
httpStatus: cause.status,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
normalized.includes('network') ||
|
|
165
|
+
normalized.includes('fetch') ||
|
|
166
|
+
normalized.includes('timeout') ||
|
|
167
|
+
normalized.includes('offline')
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
code: 'NETWORK_ERROR',
|
|
171
|
+
message,
|
|
172
|
+
cause,
|
|
173
|
+
retryable: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (normalized.includes('conflict')) {
|
|
178
|
+
return {
|
|
179
|
+
code: 'CONFLICT',
|
|
180
|
+
message,
|
|
181
|
+
cause,
|
|
182
|
+
retryable: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
71
186
|
return {
|
|
72
|
-
code,
|
|
187
|
+
code: 'SYNC_ERROR',
|
|
73
188
|
message,
|
|
74
189
|
cause,
|
|
75
|
-
|
|
190
|
+
retryable: false,
|
|
76
191
|
};
|
|
77
192
|
}
|
|
78
193
|
|
|
@@ -109,6 +224,15 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
109
224
|
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
110
225
|
private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
111
226
|
private hasRealtimeConnectedOnce = false;
|
|
227
|
+
private transportHealth: TransportHealth = {
|
|
228
|
+
mode: 'disconnected',
|
|
229
|
+
connected: false,
|
|
230
|
+
lastSuccessfulPollAt: null,
|
|
231
|
+
lastRealtimeMessageAt: null,
|
|
232
|
+
fallbackReason: null,
|
|
233
|
+
};
|
|
234
|
+
private activeBootstrapSubscriptions = new Set<string>();
|
|
235
|
+
private bootstrapStartedAt = new Map<string, number>();
|
|
112
236
|
|
|
113
237
|
/**
|
|
114
238
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
@@ -134,6 +258,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
134
258
|
this.config = config;
|
|
135
259
|
this.listeners = new Map();
|
|
136
260
|
this.state = this.createInitialState();
|
|
261
|
+
this.transportHealth = {
|
|
262
|
+
mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
|
|
263
|
+
connected: false,
|
|
264
|
+
lastSuccessfulPollAt: null,
|
|
265
|
+
lastRealtimeMessageAt: null,
|
|
266
|
+
fallbackReason: null,
|
|
267
|
+
};
|
|
137
268
|
}
|
|
138
269
|
|
|
139
270
|
/**
|
|
@@ -308,6 +439,185 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
308
439
|
return this.state;
|
|
309
440
|
}
|
|
310
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Get transport health details (realtime/polling/fallback).
|
|
444
|
+
*/
|
|
445
|
+
getTransportHealth(): Readonly<TransportHealth> {
|
|
446
|
+
return this.transportHealth;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Get subscription state metadata for the current profile.
|
|
451
|
+
*/
|
|
452
|
+
async listSubscriptionStates(args?: {
|
|
453
|
+
stateId?: string;
|
|
454
|
+
table?: string;
|
|
455
|
+
status?: 'active' | 'revoked';
|
|
456
|
+
}): Promise<SubscriptionState[]> {
|
|
457
|
+
return readSubscriptionStates(this.config.db, {
|
|
458
|
+
stateId: args?.stateId ?? this.getStateId(),
|
|
459
|
+
table: args?.table,
|
|
460
|
+
status: args?.status,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Get a single subscription state by id.
|
|
466
|
+
*/
|
|
467
|
+
async getSubscriptionState(
|
|
468
|
+
subscriptionId: string,
|
|
469
|
+
options?: { stateId?: string }
|
|
470
|
+
): Promise<SubscriptionState | null> {
|
|
471
|
+
return readSubscriptionState(this.config.db, {
|
|
472
|
+
stateId: options?.stateId ?? this.getStateId(),
|
|
473
|
+
subscriptionId,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Get normalized progress for all active subscriptions in this state profile.
|
|
479
|
+
*/
|
|
480
|
+
async getProgress(): Promise<SyncProgress> {
|
|
481
|
+
const subscriptions = await this.listSubscriptionStates();
|
|
482
|
+
const progress = subscriptions.map((sub) =>
|
|
483
|
+
this.mapSubscriptionToProgress(sub)
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const channelPhase = this.resolveChannelPhase(progress);
|
|
487
|
+
const hasSubscriptions = progress.length > 0;
|
|
488
|
+
const basePercent = hasSubscriptions
|
|
489
|
+
? Math.round(
|
|
490
|
+
progress.reduce((sum, item) => sum + item.progressPercent, 0) /
|
|
491
|
+
progress.length
|
|
492
|
+
)
|
|
493
|
+
: this.state.lastSyncAt !== null
|
|
494
|
+
? 100
|
|
495
|
+
: 0;
|
|
496
|
+
|
|
497
|
+
const progressPercent =
|
|
498
|
+
channelPhase === 'live'
|
|
499
|
+
? 100
|
|
500
|
+
: Math.max(0, Math.min(100, Math.trunc(basePercent)));
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
channelPhase,
|
|
504
|
+
progressPercent,
|
|
505
|
+
subscriptions: progress,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Wait until the channel reaches a target phase.
|
|
511
|
+
*/
|
|
512
|
+
async awaitPhase(
|
|
513
|
+
phase: SyncProgress['channelPhase'],
|
|
514
|
+
options: SyncAwaitPhaseOptions = {}
|
|
515
|
+
): Promise<SyncProgress> {
|
|
516
|
+
const timeoutMs = Math.max(
|
|
517
|
+
0,
|
|
518
|
+
options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS
|
|
519
|
+
);
|
|
520
|
+
const deadline = Date.now() + timeoutMs;
|
|
521
|
+
|
|
522
|
+
while (true) {
|
|
523
|
+
const progress = await this.getProgress();
|
|
524
|
+
|
|
525
|
+
if (progress.channelPhase === phase) {
|
|
526
|
+
return progress;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (progress.channelPhase === 'error') {
|
|
530
|
+
const message = this.state.error?.message ?? 'Sync entered error state';
|
|
531
|
+
throw new Error(
|
|
532
|
+
`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const remainingMs = deadline - Date.now();
|
|
537
|
+
if (remainingMs <= 0) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
await this.waitForProgressSignal(remainingMs);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Wait until bootstrap finishes for a state or a specific subscription.
|
|
549
|
+
*/
|
|
550
|
+
async awaitBootstrapComplete(
|
|
551
|
+
options: SyncAwaitBootstrapOptions = {}
|
|
552
|
+
): Promise<SyncProgress> {
|
|
553
|
+
const timeoutMs = Math.max(
|
|
554
|
+
0,
|
|
555
|
+
options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS
|
|
556
|
+
);
|
|
557
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
558
|
+
const deadline = Date.now() + timeoutMs;
|
|
559
|
+
|
|
560
|
+
while (true) {
|
|
561
|
+
const states = await this.listSubscriptionStates({ stateId });
|
|
562
|
+
const relevantStates =
|
|
563
|
+
options.subscriptionId === undefined
|
|
564
|
+
? states
|
|
565
|
+
: states.filter(
|
|
566
|
+
(state) => state.subscriptionId === options.subscriptionId
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const hasPendingBootstrap = relevantStates.some(
|
|
570
|
+
(state) => state.status === 'active' && state.bootstrapState !== null
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
if (!hasPendingBootstrap) {
|
|
574
|
+
return this.getProgress();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (this.state.error) {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const remainingMs = deadline - Date.now();
|
|
584
|
+
if (remainingMs <= 0) {
|
|
585
|
+
const target =
|
|
586
|
+
options.subscriptionId === undefined
|
|
587
|
+
? `state "${stateId}"`
|
|
588
|
+
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
589
|
+
|
|
590
|
+
throw new Error(
|
|
591
|
+
`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await this.waitForProgressSignal(remainingMs);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Get a diagnostics snapshot suitable for debug UIs and bug reports.
|
|
601
|
+
*/
|
|
602
|
+
async getDiagnostics(): Promise<SyncDiagnostics> {
|
|
603
|
+
const [subscriptions, progress, outbox, conflicts] = await Promise.all([
|
|
604
|
+
this.listSubscriptionStates(),
|
|
605
|
+
this.getProgress(),
|
|
606
|
+
this.refreshOutboxStats({ emit: false }),
|
|
607
|
+
this.getConflicts(),
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
timestamp: Date.now(),
|
|
612
|
+
state: this.state,
|
|
613
|
+
transport: this.transportHealth,
|
|
614
|
+
progress,
|
|
615
|
+
outbox,
|
|
616
|
+
conflictCount: conflicts.length,
|
|
617
|
+
subscriptions,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
311
621
|
/**
|
|
312
622
|
* Get database instance
|
|
313
623
|
*/
|
|
@@ -329,6 +639,444 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
329
639
|
return this.config.clientId;
|
|
330
640
|
}
|
|
331
641
|
|
|
642
|
+
private getStateId(): string {
|
|
643
|
+
return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private makeBootstrapKey(stateId: string, subscriptionId: string): string {
|
|
647
|
+
return `${stateId}:${subscriptionId}`;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private updateTransportHealth(partial: Partial<TransportHealth>): void {
|
|
651
|
+
this.transportHealth = {
|
|
652
|
+
...this.transportHealth,
|
|
653
|
+
...partial,
|
|
654
|
+
};
|
|
655
|
+
this.emit('state:change', {});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private waitForProgressSignal(timeoutMs: number): Promise<void> {
|
|
659
|
+
return new Promise((resolve) => {
|
|
660
|
+
const cleanups: Array<() => void> = [];
|
|
661
|
+
let settled = false;
|
|
662
|
+
|
|
663
|
+
const finish = () => {
|
|
664
|
+
if (settled) return;
|
|
665
|
+
settled = true;
|
|
666
|
+
clearTimeout(timeoutId);
|
|
667
|
+
for (const cleanup of cleanups) cleanup();
|
|
668
|
+
resolve();
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const listen = (event: SyncEventType) => {
|
|
672
|
+
cleanups.push(this.on(event, finish));
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
listen('sync:start');
|
|
676
|
+
listen('sync:complete');
|
|
677
|
+
listen('sync:error');
|
|
678
|
+
listen('sync:live');
|
|
679
|
+
listen('bootstrap:start');
|
|
680
|
+
listen('bootstrap:progress');
|
|
681
|
+
listen('bootstrap:complete');
|
|
682
|
+
|
|
683
|
+
const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private mapSubscriptionToProgress(
|
|
688
|
+
subscription: SubscriptionState
|
|
689
|
+
): SubscriptionProgress {
|
|
690
|
+
if (subscription.status === 'revoked') {
|
|
691
|
+
return {
|
|
692
|
+
stateId: subscription.stateId,
|
|
693
|
+
id: subscription.subscriptionId,
|
|
694
|
+
table: subscription.table,
|
|
695
|
+
phase: 'error',
|
|
696
|
+
progressPercent: 0,
|
|
697
|
+
startedAt: subscription.createdAt,
|
|
698
|
+
completedAt: subscription.updatedAt,
|
|
699
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
700
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (subscription.bootstrapState) {
|
|
705
|
+
const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
|
|
706
|
+
const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
|
|
707
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
708
|
+
const progressPercent =
|
|
709
|
+
tableCount === 0
|
|
710
|
+
? 0
|
|
711
|
+
: Math.max(
|
|
712
|
+
0,
|
|
713
|
+
Math.min(100, Math.round((tablesProcessed / tableCount) * 100))
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
stateId: subscription.stateId,
|
|
718
|
+
id: subscription.subscriptionId,
|
|
719
|
+
table: subscription.table,
|
|
720
|
+
phase: 'bootstrapping',
|
|
721
|
+
progressPercent,
|
|
722
|
+
tablesProcessed,
|
|
723
|
+
tablesTotal: tableCount,
|
|
724
|
+
startedAt: this.bootstrapStartedAt.get(
|
|
725
|
+
this.makeBootstrapKey(
|
|
726
|
+
subscription.stateId,
|
|
727
|
+
subscription.subscriptionId
|
|
728
|
+
)
|
|
729
|
+
),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (this.state.error) {
|
|
734
|
+
return {
|
|
735
|
+
stateId: subscription.stateId,
|
|
736
|
+
id: subscription.subscriptionId,
|
|
737
|
+
table: subscription.table,
|
|
738
|
+
phase: 'error',
|
|
739
|
+
progressPercent: subscription.cursor >= 0 ? 100 : 0,
|
|
740
|
+
startedAt: subscription.createdAt,
|
|
741
|
+
lastErrorCode: this.state.error.code,
|
|
742
|
+
lastErrorMessage: this.state.error.message,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (this.state.isSyncing) {
|
|
747
|
+
return {
|
|
748
|
+
stateId: subscription.stateId,
|
|
749
|
+
id: subscription.subscriptionId,
|
|
750
|
+
table: subscription.table,
|
|
751
|
+
phase: 'catching_up',
|
|
752
|
+
progressPercent: subscription.cursor >= 0 ? 90 : 0,
|
|
753
|
+
startedAt: subscription.createdAt,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
|
|
758
|
+
return {
|
|
759
|
+
stateId: subscription.stateId,
|
|
760
|
+
id: subscription.subscriptionId,
|
|
761
|
+
table: subscription.table,
|
|
762
|
+
phase: 'live',
|
|
763
|
+
progressPercent: 100,
|
|
764
|
+
startedAt: subscription.createdAt,
|
|
765
|
+
completedAt: subscription.updatedAt,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
stateId: subscription.stateId,
|
|
771
|
+
id: subscription.subscriptionId,
|
|
772
|
+
table: subscription.table,
|
|
773
|
+
phase: 'idle',
|
|
774
|
+
progressPercent: 0,
|
|
775
|
+
startedAt: subscription.createdAt,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private resolveChannelPhase(
|
|
780
|
+
subscriptions: SubscriptionProgress[]
|
|
781
|
+
): SyncProgress['channelPhase'] {
|
|
782
|
+
if (this.state.error) return 'error';
|
|
783
|
+
if (subscriptions.some((sub) => sub.phase === 'error')) return 'error';
|
|
784
|
+
if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
|
|
785
|
+
return 'bootstrapping';
|
|
786
|
+
}
|
|
787
|
+
if (this.state.isSyncing) {
|
|
788
|
+
return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
|
|
789
|
+
}
|
|
790
|
+
if (this.state.lastSyncAt !== null) return 'live';
|
|
791
|
+
return 'idle';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private deriveProgressFromPullSubscription(
|
|
795
|
+
sub: SyncPullSubscriptionResponse
|
|
796
|
+
): SubscriptionProgress {
|
|
797
|
+
const stateId = this.getStateId();
|
|
798
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
799
|
+
const startedAt = this.bootstrapStartedAt.get(key);
|
|
800
|
+
|
|
801
|
+
if (sub.status === 'revoked') {
|
|
802
|
+
return {
|
|
803
|
+
stateId,
|
|
804
|
+
id: sub.id,
|
|
805
|
+
phase: 'error',
|
|
806
|
+
progressPercent: 0,
|
|
807
|
+
startedAt,
|
|
808
|
+
completedAt: Date.now(),
|
|
809
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
810
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (sub.bootstrap && sub.bootstrapState) {
|
|
815
|
+
const tableCount = Math.max(0, sub.bootstrapState.tables.length);
|
|
816
|
+
const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
|
|
817
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
818
|
+
const progressPercent =
|
|
819
|
+
tableCount === 0
|
|
820
|
+
? 0
|
|
821
|
+
: Math.max(
|
|
822
|
+
0,
|
|
823
|
+
Math.min(100, Math.round((tablesProcessed / tableCount) * 100))
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
stateId,
|
|
828
|
+
id: sub.id,
|
|
829
|
+
phase: 'bootstrapping',
|
|
830
|
+
progressPercent,
|
|
831
|
+
tablesProcessed,
|
|
832
|
+
tablesTotal: tableCount,
|
|
833
|
+
startedAt,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
stateId,
|
|
839
|
+
id: sub.id,
|
|
840
|
+
phase: this.state.isSyncing ? 'catching_up' : 'live',
|
|
841
|
+
progressPercent: this.state.isSyncing ? 90 : 100,
|
|
842
|
+
startedAt,
|
|
843
|
+
completedAt: this.state.isSyncing ? undefined : Date.now(),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private handleBootstrapLifecycle(response: SyncPullResponse): void {
|
|
848
|
+
const stateId = this.getStateId();
|
|
849
|
+
const now = Date.now();
|
|
850
|
+
const seenKeys = new Set<string>();
|
|
851
|
+
|
|
852
|
+
for (const sub of response.subscriptions ?? []) {
|
|
853
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
854
|
+
seenKeys.add(key);
|
|
855
|
+
const isBootstrapping = sub.bootstrap === true;
|
|
856
|
+
const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
|
|
857
|
+
|
|
858
|
+
if (isBootstrapping && !wasBootstrapping) {
|
|
859
|
+
this.activeBootstrapSubscriptions.add(key);
|
|
860
|
+
this.bootstrapStartedAt.set(key, now);
|
|
861
|
+
this.emit('bootstrap:start', {
|
|
862
|
+
timestamp: now,
|
|
863
|
+
stateId,
|
|
864
|
+
subscriptionId: sub.id,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (isBootstrapping) {
|
|
869
|
+
this.emit('bootstrap:progress', {
|
|
870
|
+
timestamp: now,
|
|
871
|
+
stateId,
|
|
872
|
+
subscriptionId: sub.id,
|
|
873
|
+
progress: this.deriveProgressFromPullSubscription(sub),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (!isBootstrapping && wasBootstrapping) {
|
|
878
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
879
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
880
|
+
this.bootstrapStartedAt.delete(key);
|
|
881
|
+
this.emit('bootstrap:complete', {
|
|
882
|
+
timestamp: now,
|
|
883
|
+
stateId,
|
|
884
|
+
subscriptionId: sub.id,
|
|
885
|
+
durationMs: Math.max(0, now - startedAt),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
for (const key of Array.from(this.activeBootstrapSubscriptions)) {
|
|
891
|
+
if (seenKeys.has(key)) continue;
|
|
892
|
+
if (!key.startsWith(`${stateId}:`)) continue;
|
|
893
|
+
const subscriptionId = key.slice(stateId.length + 1);
|
|
894
|
+
if (!subscriptionId) continue;
|
|
895
|
+
|
|
896
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
897
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
898
|
+
this.bootstrapStartedAt.delete(key);
|
|
899
|
+
this.emit('bootstrap:complete', {
|
|
900
|
+
timestamp: now,
|
|
901
|
+
stateId,
|
|
902
|
+
subscriptionId,
|
|
903
|
+
durationMs: Math.max(0, now - startedAt),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
|
|
908
|
+
this.emit('sync:live', { timestamp: now });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
private async resolveResetTargets(
|
|
913
|
+
options: SyncResetOptions
|
|
914
|
+
): Promise<SubscriptionState[]> {
|
|
915
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
916
|
+
|
|
917
|
+
if (options.scope === 'all') {
|
|
918
|
+
return readSubscriptionStates(this.config.db);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (options.scope === 'state') {
|
|
922
|
+
return readSubscriptionStates(this.config.db, { stateId });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const subscriptionIds = options.subscriptionIds ?? [];
|
|
926
|
+
if (subscriptionIds.length === 0) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
'[SyncEngine.reset] subscriptionIds is required when scope="subscription"'
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const allInState = await readSubscriptionStates(this.config.db, {
|
|
933
|
+
stateId,
|
|
934
|
+
});
|
|
935
|
+
const wanted = new Set(subscriptionIds);
|
|
936
|
+
return allInState.filter((state) => wanted.has(state.subscriptionId));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private async clearSyncedTablesForReset(
|
|
940
|
+
trx: Transaction<DB>,
|
|
941
|
+
options: SyncResetOptions,
|
|
942
|
+
targets: SubscriptionState[]
|
|
943
|
+
): Promise<string[]> {
|
|
944
|
+
const clearedTables: string[] = [];
|
|
945
|
+
|
|
946
|
+
if (!options.clearSyncedTables) {
|
|
947
|
+
return clearedTables;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (options.scope === 'all') {
|
|
951
|
+
for (const handler of this.config.handlers.getAll()) {
|
|
952
|
+
await handler.clearAll({ trx, scopes: {} });
|
|
953
|
+
clearedTables.push(handler.table);
|
|
954
|
+
}
|
|
955
|
+
return clearedTables;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const seen = new Set<string>();
|
|
959
|
+
for (const target of targets) {
|
|
960
|
+
const handler = this.config.handlers.get(target.table);
|
|
961
|
+
if (!handler) continue;
|
|
962
|
+
|
|
963
|
+
const key = `${target.table}:${JSON.stringify(target.scopes)}`;
|
|
964
|
+
if (seen.has(key)) continue;
|
|
965
|
+
seen.add(key);
|
|
966
|
+
|
|
967
|
+
await handler.clearAll({ trx, scopes: target.scopes });
|
|
968
|
+
clearedTables.push(target.table);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return clearedTables;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async reset(options: SyncResetOptions): Promise<SyncResetResult> {
|
|
975
|
+
const resetOptions: SyncResetOptions = {
|
|
976
|
+
clearOutbox: false,
|
|
977
|
+
clearConflicts: false,
|
|
978
|
+
clearSyncedTables: false,
|
|
979
|
+
...options,
|
|
980
|
+
};
|
|
981
|
+
const targets = await this.resolveResetTargets(resetOptions);
|
|
982
|
+
const stateId = resetOptions.stateId ?? this.getStateId();
|
|
983
|
+
|
|
984
|
+
this.stop();
|
|
985
|
+
|
|
986
|
+
const result = await this.config.db.transaction().execute(async (trx) => {
|
|
987
|
+
const clearedTables = await this.clearSyncedTablesForReset(
|
|
988
|
+
trx,
|
|
989
|
+
resetOptions,
|
|
990
|
+
targets
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
let deletedSubscriptionStates = 0;
|
|
994
|
+
if (resetOptions.scope === 'all') {
|
|
995
|
+
const res = await sql`
|
|
996
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
997
|
+
`.execute(trx);
|
|
998
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
999
|
+
} else if (resetOptions.scope === 'state') {
|
|
1000
|
+
const res = await sql`
|
|
1001
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1002
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
1003
|
+
`.execute(trx);
|
|
1004
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
1005
|
+
} else {
|
|
1006
|
+
const subscriptionIds = resetOptions.subscriptionIds ?? [];
|
|
1007
|
+
const res = await sql`
|
|
1008
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1009
|
+
where
|
|
1010
|
+
${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
1011
|
+
and ${sql.ref('subscription_id')} in (${sql.join(
|
|
1012
|
+
subscriptionIds.map((id) => sql.val(id))
|
|
1013
|
+
)})
|
|
1014
|
+
`.execute(trx);
|
|
1015
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
let deletedOutboxCommits = 0;
|
|
1019
|
+
if (resetOptions.clearOutbox) {
|
|
1020
|
+
const res = await sql`
|
|
1021
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
1022
|
+
`.execute(trx);
|
|
1023
|
+
deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
let deletedConflicts = 0;
|
|
1027
|
+
if (resetOptions.clearConflicts) {
|
|
1028
|
+
const res = await sql`
|
|
1029
|
+
delete from ${sql.table('sync_conflicts')}
|
|
1030
|
+
`.execute(trx);
|
|
1031
|
+
deletedConflicts = Number(res.numAffectedRows ?? 0);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
deletedSubscriptionStates,
|
|
1036
|
+
deletedOutboxCommits,
|
|
1037
|
+
deletedConflicts,
|
|
1038
|
+
clearedTables,
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
if (resetOptions.scope === 'all') {
|
|
1043
|
+
this.activeBootstrapSubscriptions.clear();
|
|
1044
|
+
this.bootstrapStartedAt.clear();
|
|
1045
|
+
} else {
|
|
1046
|
+
for (const target of targets) {
|
|
1047
|
+
const key = this.makeBootstrapKey(
|
|
1048
|
+
target.stateId,
|
|
1049
|
+
target.subscriptionId
|
|
1050
|
+
);
|
|
1051
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
1052
|
+
this.bootstrapStartedAt.delete(key);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
this.resetLocalState();
|
|
1057
|
+
await this.refreshOutboxStats();
|
|
1058
|
+
this.updateState({ error: null });
|
|
1059
|
+
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async repair(options: SyncRepairOptions): Promise<SyncResetResult> {
|
|
1064
|
+
if (options.mode !== 'rebootstrap-missing-chunks') {
|
|
1065
|
+
throw new Error(
|
|
1066
|
+
`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return this.reset({
|
|
1071
|
+
scope: options.subscriptionIds ? 'subscription' : 'state',
|
|
1072
|
+
stateId: options.stateId,
|
|
1073
|
+
subscriptionIds: options.subscriptionIds,
|
|
1074
|
+
clearOutbox: options.clearOutbox ?? false,
|
|
1075
|
+
clearConflicts: options.clearConflicts ?? false,
|
|
1076
|
+
clearSyncedTables: true,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
332
1080
|
/**
|
|
333
1081
|
* Subscribe to sync events
|
|
334
1082
|
*/
|
|
@@ -441,11 +1189,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
441
1189
|
const migrationError =
|
|
442
1190
|
err instanceof Error ? err : new Error(String(err));
|
|
443
1191
|
this.config.onMigrationError?.(migrationError);
|
|
444
|
-
const error = createSyncError(
|
|
445
|
-
'
|
|
446
|
-
'Migration failed',
|
|
447
|
-
migrationError
|
|
448
|
-
|
|
1192
|
+
const error = createSyncError({
|
|
1193
|
+
code: 'MIGRATION_FAILED',
|
|
1194
|
+
message: 'Migration failed',
|
|
1195
|
+
cause: migrationError,
|
|
1196
|
+
retryable: false,
|
|
1197
|
+
stateId: this.getStateId(),
|
|
1198
|
+
});
|
|
1199
|
+
this.updateState({
|
|
1200
|
+
isSyncing: false,
|
|
1201
|
+
error,
|
|
1202
|
+
});
|
|
449
1203
|
this.handleError(error);
|
|
450
1204
|
return;
|
|
451
1205
|
}
|
|
@@ -513,7 +1267,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
513
1267
|
pushedCommits: 0,
|
|
514
1268
|
pullRounds: 0,
|
|
515
1269
|
pullResponse: { ok: true, subscriptions: [] },
|
|
516
|
-
error: createSyncError(
|
|
1270
|
+
error: createSyncError({
|
|
1271
|
+
code: 'SYNC_ERROR',
|
|
1272
|
+
message: 'Sync not enabled',
|
|
1273
|
+
retryable: false,
|
|
1274
|
+
stateId: this.getStateId(),
|
|
1275
|
+
}),
|
|
517
1276
|
};
|
|
518
1277
|
}
|
|
519
1278
|
|
|
@@ -533,7 +1292,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
533
1292
|
pushedCommits: 0,
|
|
534
1293
|
pullRounds: 0,
|
|
535
1294
|
pullResponse: { ok: true, subscriptions: [] },
|
|
536
|
-
error: createSyncError(
|
|
1295
|
+
error: createSyncError({
|
|
1296
|
+
code: 'SYNC_ERROR',
|
|
1297
|
+
message: 'Sync not started',
|
|
1298
|
+
retryable: false,
|
|
1299
|
+
stateId: this.getStateId(),
|
|
1300
|
+
}),
|
|
537
1301
|
};
|
|
538
1302
|
|
|
539
1303
|
do {
|
|
@@ -614,6 +1378,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
614
1378
|
retryCount: 0,
|
|
615
1379
|
isRetrying: false,
|
|
616
1380
|
});
|
|
1381
|
+
this.updateTransportHealth({
|
|
1382
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1383
|
+
});
|
|
617
1384
|
|
|
618
1385
|
this.emit('sync:complete', {
|
|
619
1386
|
timestamp: Date.now(),
|
|
@@ -631,6 +1398,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
631
1398
|
});
|
|
632
1399
|
this.config.onDataChange?.(changedTables);
|
|
633
1400
|
}
|
|
1401
|
+
this.handleBootstrapLifecycle(result.pullResponse);
|
|
634
1402
|
|
|
635
1403
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
636
1404
|
this.refreshOutboxStats().catch((error) => {
|
|
@@ -671,11 +1439,15 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
671
1439
|
|
|
672
1440
|
return syncResult;
|
|
673
1441
|
} catch (err) {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1442
|
+
const classified = classifySyncFailure(err);
|
|
1443
|
+
const error = createSyncError({
|
|
1444
|
+
code: classified.code,
|
|
1445
|
+
message: classified.message,
|
|
1446
|
+
cause: classified.cause,
|
|
1447
|
+
retryable: classified.retryable,
|
|
1448
|
+
httpStatus: classified.httpStatus,
|
|
1449
|
+
stateId: this.getStateId(),
|
|
1450
|
+
});
|
|
679
1451
|
|
|
680
1452
|
this.updateState({
|
|
681
1453
|
isSyncing: false,
|
|
@@ -707,7 +1479,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
707
1479
|
|
|
708
1480
|
// Schedule retry if under max retries
|
|
709
1481
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
710
|
-
if (this.state.retryCount < maxRetries) {
|
|
1482
|
+
if (error.retryable && this.state.retryCount < maxRetries) {
|
|
711
1483
|
this.scheduleRetry();
|
|
712
1484
|
}
|
|
713
1485
|
|
|
@@ -886,6 +1658,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
886
1658
|
retryCount: 0,
|
|
887
1659
|
isRetrying: false,
|
|
888
1660
|
});
|
|
1661
|
+
this.updateTransportHealth({
|
|
1662
|
+
mode: 'realtime',
|
|
1663
|
+
connected: true,
|
|
1664
|
+
fallbackReason: null,
|
|
1665
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1666
|
+
});
|
|
889
1667
|
|
|
890
1668
|
this.emit('sync:complete', {
|
|
891
1669
|
timestamp: Date.now(),
|
|
@@ -893,6 +1671,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
893
1671
|
pullRounds: 0,
|
|
894
1672
|
pullResponse: { ok: true, subscriptions: [] },
|
|
895
1673
|
});
|
|
1674
|
+
this.emit('sync:live', { timestamp: Date.now() });
|
|
896
1675
|
|
|
897
1676
|
this.refreshOutboxStats().catch((error) => {
|
|
898
1677
|
console.warn(
|
|
@@ -1041,6 +1820,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1041
1820
|
}, interval);
|
|
1042
1821
|
|
|
1043
1822
|
this.setConnectionState('connected');
|
|
1823
|
+
this.updateTransportHealth({
|
|
1824
|
+
mode: 'polling',
|
|
1825
|
+
connected: true,
|
|
1826
|
+
fallbackReason: null,
|
|
1827
|
+
});
|
|
1044
1828
|
}
|
|
1045
1829
|
|
|
1046
1830
|
private stopPolling(): void {
|
|
@@ -1061,6 +1845,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1061
1845
|
}
|
|
1062
1846
|
|
|
1063
1847
|
this.setConnectionState('connecting');
|
|
1848
|
+
this.updateTransportHealth({
|
|
1849
|
+
mode: 'disconnected',
|
|
1850
|
+
connected: false,
|
|
1851
|
+
fallbackReason: null,
|
|
1852
|
+
});
|
|
1064
1853
|
|
|
1065
1854
|
const transport = this.config.transport as RealtimeTransportLike;
|
|
1066
1855
|
|
|
@@ -1089,6 +1878,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1089
1878
|
{ clientId: this.config.clientId! },
|
|
1090
1879
|
(event) => {
|
|
1091
1880
|
if (event.event === 'sync') {
|
|
1881
|
+
this.updateTransportHealth({
|
|
1882
|
+
lastRealtimeMessageAt: Date.now(),
|
|
1883
|
+
});
|
|
1092
1884
|
countSyncMetric('sync.client.ws.events', 1, {
|
|
1093
1885
|
attributes: { type: 'sync' },
|
|
1094
1886
|
});
|
|
@@ -1114,6 +1906,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1114
1906
|
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
1115
1907
|
this.hasRealtimeConnectedOnce = true;
|
|
1116
1908
|
this.setConnectionState('connected');
|
|
1909
|
+
this.updateTransportHealth({
|
|
1910
|
+
mode: 'realtime',
|
|
1911
|
+
connected: true,
|
|
1912
|
+
fallbackReason: null,
|
|
1913
|
+
});
|
|
1117
1914
|
this.stopFallbackPolling();
|
|
1118
1915
|
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
1119
1916
|
if (wasConnectedBefore) {
|
|
@@ -1123,9 +1920,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1123
1920
|
}
|
|
1124
1921
|
case 'connecting':
|
|
1125
1922
|
this.setConnectionState('connecting');
|
|
1923
|
+
this.updateTransportHealth({
|
|
1924
|
+
mode: 'disconnected',
|
|
1925
|
+
connected: false,
|
|
1926
|
+
});
|
|
1126
1927
|
break;
|
|
1127
1928
|
case 'disconnected':
|
|
1128
1929
|
this.setConnectionState('reconnecting');
|
|
1930
|
+
this.updateTransportHealth({
|
|
1931
|
+
mode: 'disconnected',
|
|
1932
|
+
connected: false,
|
|
1933
|
+
});
|
|
1129
1934
|
this.startFallbackPolling();
|
|
1130
1935
|
break;
|
|
1131
1936
|
}
|
|
@@ -1147,6 +1952,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1147
1952
|
this.realtimeDisconnect = null;
|
|
1148
1953
|
}
|
|
1149
1954
|
this.stopFallbackPolling();
|
|
1955
|
+
this.updateTransportHealth({
|
|
1956
|
+
mode: 'disconnected',
|
|
1957
|
+
connected: false,
|
|
1958
|
+
});
|
|
1150
1959
|
}
|
|
1151
1960
|
|
|
1152
1961
|
private scheduleRealtimeReconnectCatchupSync(): void {
|
|
@@ -1168,6 +1977,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1168
1977
|
if (this.fallbackPollerId) return;
|
|
1169
1978
|
|
|
1170
1979
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
1980
|
+
this.updateTransportHealth({
|
|
1981
|
+
mode: 'polling',
|
|
1982
|
+
connected: false,
|
|
1983
|
+
fallbackReason: 'network',
|
|
1984
|
+
});
|
|
1171
1985
|
this.fallbackPollerId = setInterval(() => {
|
|
1172
1986
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
1173
1987
|
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
@@ -1180,6 +1994,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1180
1994
|
clearInterval(this.fallbackPollerId);
|
|
1181
1995
|
this.fallbackPollerId = null;
|
|
1182
1996
|
}
|
|
1997
|
+
this.updateTransportHealth({ fallbackReason: null });
|
|
1183
1998
|
}
|
|
1184
1999
|
|
|
1185
2000
|
/**
|