@syncular/client 0.0.2-2 → 0.0.3-14
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
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -12,31 +12,51 @@ 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
|
+
SyncInspectorEvent,
|
|
51
|
+
SyncInspectorOptions,
|
|
52
|
+
SyncInspectorSnapshot,
|
|
53
|
+
SyncProgress,
|
|
54
|
+
SyncRepairOptions,
|
|
55
|
+
SyncResetOptions,
|
|
56
|
+
SyncResetResult,
|
|
38
57
|
SyncResult,
|
|
39
58
|
SyncTransportMode,
|
|
59
|
+
TransportHealth,
|
|
40
60
|
} from './types';
|
|
41
61
|
|
|
42
62
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
|
@@ -45,6 +65,9 @@ const INITIAL_RETRY_DELAY_MS = 1000;
|
|
|
45
65
|
const MAX_RETRY_DELAY_MS = 60000;
|
|
46
66
|
const EXPONENTIAL_FACTOR = 2;
|
|
47
67
|
const REALTIME_RECONNECT_CATCHUP_DELAY_MS = 500;
|
|
68
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 60_000;
|
|
69
|
+
const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
|
|
70
|
+
const MAX_INSPECTOR_EVENT_LIMIT = 500;
|
|
48
71
|
|
|
49
72
|
function calculateRetryDelay(attemptIndex: number): number {
|
|
50
73
|
return Math.min(
|
|
@@ -63,16 +86,113 @@ function isRealtimeTransport(
|
|
|
63
86
|
);
|
|
64
87
|
}
|
|
65
88
|
|
|
66
|
-
function createSyncError(
|
|
67
|
-
code: SyncError['code']
|
|
68
|
-
message: string
|
|
69
|
-
cause?: Error
|
|
70
|
-
|
|
89
|
+
function createSyncError(args: {
|
|
90
|
+
code: SyncError['code'];
|
|
91
|
+
message: string;
|
|
92
|
+
cause?: Error;
|
|
93
|
+
retryable?: boolean;
|
|
94
|
+
httpStatus?: number;
|
|
95
|
+
subscriptionId?: string;
|
|
96
|
+
stateId?: string;
|
|
97
|
+
}): SyncError {
|
|
98
|
+
return {
|
|
99
|
+
code: args.code,
|
|
100
|
+
message: args.message,
|
|
101
|
+
cause: args.cause,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
retryable: args.retryable ?? false,
|
|
104
|
+
httpStatus: args.httpStatus,
|
|
105
|
+
subscriptionId: args.subscriptionId,
|
|
106
|
+
stateId: args.stateId,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifySyncFailure(error: unknown): {
|
|
111
|
+
code: SyncError['code'];
|
|
112
|
+
message: string;
|
|
113
|
+
cause: Error;
|
|
114
|
+
retryable: boolean;
|
|
115
|
+
httpStatus?: number;
|
|
116
|
+
} {
|
|
117
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
118
|
+
const message = cause.message || 'Sync failed';
|
|
119
|
+
const normalized = message.toLowerCase();
|
|
120
|
+
|
|
121
|
+
if (cause instanceof SyncTransportError) {
|
|
122
|
+
if (cause.status === 401 || cause.status === 403) {
|
|
123
|
+
return {
|
|
124
|
+
code: 'AUTH_FAILED',
|
|
125
|
+
message,
|
|
126
|
+
cause,
|
|
127
|
+
retryable: false,
|
|
128
|
+
httpStatus: cause.status,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
cause.status === 404 &&
|
|
134
|
+
normalized.includes('snapshot') &&
|
|
135
|
+
normalized.includes('chunk')
|
|
136
|
+
) {
|
|
137
|
+
return {
|
|
138
|
+
code: 'SNAPSHOT_CHUNK_NOT_FOUND',
|
|
139
|
+
message,
|
|
140
|
+
cause,
|
|
141
|
+
retryable: false,
|
|
142
|
+
httpStatus: cause.status,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
cause.status !== undefined &&
|
|
148
|
+
(cause.status >= 500 || cause.status === 408 || cause.status === 429)
|
|
149
|
+
) {
|
|
150
|
+
return {
|
|
151
|
+
code: 'NETWORK_ERROR',
|
|
152
|
+
message,
|
|
153
|
+
cause,
|
|
154
|
+
retryable: true,
|
|
155
|
+
httpStatus: cause.status,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
code: 'SYNC_ERROR',
|
|
161
|
+
message,
|
|
162
|
+
cause,
|
|
163
|
+
retryable: false,
|
|
164
|
+
httpStatus: cause.status,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
normalized.includes('network') ||
|
|
170
|
+
normalized.includes('fetch') ||
|
|
171
|
+
normalized.includes('timeout') ||
|
|
172
|
+
normalized.includes('offline')
|
|
173
|
+
) {
|
|
174
|
+
return {
|
|
175
|
+
code: 'NETWORK_ERROR',
|
|
176
|
+
message,
|
|
177
|
+
cause,
|
|
178
|
+
retryable: true,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (normalized.includes('conflict')) {
|
|
183
|
+
return {
|
|
184
|
+
code: 'CONFLICT',
|
|
185
|
+
message,
|
|
186
|
+
cause,
|
|
187
|
+
retryable: false,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
71
191
|
return {
|
|
72
|
-
code,
|
|
192
|
+
code: 'SYNC_ERROR',
|
|
73
193
|
message,
|
|
74
194
|
cause,
|
|
75
|
-
|
|
195
|
+
retryable: false,
|
|
76
196
|
};
|
|
77
197
|
}
|
|
78
198
|
|
|
@@ -82,6 +202,33 @@ function resolveSyncTriggerLabel(
|
|
|
82
202
|
return trigger ?? 'auto';
|
|
83
203
|
}
|
|
84
204
|
|
|
205
|
+
function serializeInspectorValue(value: unknown): unknown {
|
|
206
|
+
const encoded = JSON.stringify(value, (_key, nextValue) => {
|
|
207
|
+
if (nextValue instanceof Error) {
|
|
208
|
+
return {
|
|
209
|
+
name: nextValue.name,
|
|
210
|
+
message: nextValue.message,
|
|
211
|
+
stack: nextValue.stack,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (typeof nextValue === 'bigint') {
|
|
215
|
+
return nextValue.toString();
|
|
216
|
+
}
|
|
217
|
+
return nextValue;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!encoded) return null;
|
|
221
|
+
return JSON.parse(encoded) as unknown;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function serializeInspectorRecord(value: unknown): Record<string, unknown> {
|
|
225
|
+
const serialized = serializeInspectorValue(value);
|
|
226
|
+
if (isRecord(serialized)) {
|
|
227
|
+
return serialized;
|
|
228
|
+
}
|
|
229
|
+
return { value: serialized };
|
|
230
|
+
}
|
|
231
|
+
|
|
85
232
|
/**
|
|
86
233
|
* Sync engine that orchestrates push/pull cycles with proper lifecycle management.
|
|
87
234
|
*
|
|
@@ -109,6 +256,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
109
256
|
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
110
257
|
private realtimeCatchupTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
111
258
|
private hasRealtimeConnectedOnce = false;
|
|
259
|
+
private transportHealth: TransportHealth = {
|
|
260
|
+
mode: 'disconnected',
|
|
261
|
+
connected: false,
|
|
262
|
+
lastSuccessfulPollAt: null,
|
|
263
|
+
lastRealtimeMessageAt: null,
|
|
264
|
+
fallbackReason: null,
|
|
265
|
+
};
|
|
266
|
+
private activeBootstrapSubscriptions = new Set<string>();
|
|
267
|
+
private bootstrapStartedAt = new Map<string, number>();
|
|
268
|
+
private inspectorEvents: SyncInspectorEvent[] = [];
|
|
269
|
+
private nextInspectorEventId = 1;
|
|
112
270
|
|
|
113
271
|
/**
|
|
114
272
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
@@ -134,6 +292,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
134
292
|
this.config = config;
|
|
135
293
|
this.listeners = new Map();
|
|
136
294
|
this.state = this.createInitialState();
|
|
295
|
+
this.transportHealth = {
|
|
296
|
+
mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
|
|
297
|
+
connected: false,
|
|
298
|
+
lastSuccessfulPollAt: null,
|
|
299
|
+
lastRealtimeMessageAt: null,
|
|
300
|
+
fallbackReason: null,
|
|
301
|
+
};
|
|
137
302
|
}
|
|
138
303
|
|
|
139
304
|
/**
|
|
@@ -308,6 +473,208 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
308
473
|
return this.state;
|
|
309
474
|
}
|
|
310
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Get transport health details (realtime/polling/fallback).
|
|
478
|
+
*/
|
|
479
|
+
getTransportHealth(): Readonly<TransportHealth> {
|
|
480
|
+
return this.transportHealth;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Get subscription state metadata for the current profile.
|
|
485
|
+
*/
|
|
486
|
+
async listSubscriptionStates(args?: {
|
|
487
|
+
stateId?: string;
|
|
488
|
+
table?: string;
|
|
489
|
+
status?: 'active' | 'revoked';
|
|
490
|
+
}): Promise<SubscriptionState[]> {
|
|
491
|
+
return readSubscriptionStates(this.config.db, {
|
|
492
|
+
stateId: args?.stateId ?? this.getStateId(),
|
|
493
|
+
table: args?.table,
|
|
494
|
+
status: args?.status,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Get a single subscription state by id.
|
|
500
|
+
*/
|
|
501
|
+
async getSubscriptionState(
|
|
502
|
+
subscriptionId: string,
|
|
503
|
+
options?: { stateId?: string }
|
|
504
|
+
): Promise<SubscriptionState | null> {
|
|
505
|
+
return readSubscriptionState(this.config.db, {
|
|
506
|
+
stateId: options?.stateId ?? this.getStateId(),
|
|
507
|
+
subscriptionId,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Get normalized progress for all active subscriptions in this state profile.
|
|
513
|
+
*/
|
|
514
|
+
async getProgress(): Promise<SyncProgress> {
|
|
515
|
+
const subscriptions = await this.listSubscriptionStates();
|
|
516
|
+
const progress = subscriptions.map((sub) =>
|
|
517
|
+
this.mapSubscriptionToProgress(sub)
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const channelPhase = this.resolveChannelPhase(progress);
|
|
521
|
+
const hasSubscriptions = progress.length > 0;
|
|
522
|
+
const basePercent = hasSubscriptions
|
|
523
|
+
? Math.round(
|
|
524
|
+
progress.reduce((sum, item) => sum + item.progressPercent, 0) /
|
|
525
|
+
progress.length
|
|
526
|
+
)
|
|
527
|
+
: this.state.lastSyncAt !== null
|
|
528
|
+
? 100
|
|
529
|
+
: 0;
|
|
530
|
+
|
|
531
|
+
const progressPercent =
|
|
532
|
+
channelPhase === 'live'
|
|
533
|
+
? 100
|
|
534
|
+
: Math.max(0, Math.min(100, Math.trunc(basePercent)));
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
channelPhase,
|
|
538
|
+
progressPercent,
|
|
539
|
+
subscriptions: progress,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Wait until the channel reaches a target phase.
|
|
545
|
+
*/
|
|
546
|
+
async awaitPhase(
|
|
547
|
+
phase: SyncProgress['channelPhase'],
|
|
548
|
+
options: SyncAwaitPhaseOptions = {}
|
|
549
|
+
): Promise<SyncProgress> {
|
|
550
|
+
const timeoutMs = Math.max(
|
|
551
|
+
0,
|
|
552
|
+
options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS
|
|
553
|
+
);
|
|
554
|
+
const deadline = Date.now() + timeoutMs;
|
|
555
|
+
|
|
556
|
+
while (true) {
|
|
557
|
+
const progress = await this.getProgress();
|
|
558
|
+
|
|
559
|
+
if (progress.channelPhase === phase) {
|
|
560
|
+
return progress;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (progress.channelPhase === 'error') {
|
|
564
|
+
const message = this.state.error?.message ?? 'Sync entered error state';
|
|
565
|
+
throw new Error(
|
|
566
|
+
`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const remainingMs = deadline - Date.now();
|
|
571
|
+
if (remainingMs <= 0) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
await this.waitForProgressSignal(remainingMs);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Wait until bootstrap finishes for a state or a specific subscription.
|
|
583
|
+
*/
|
|
584
|
+
async awaitBootstrapComplete(
|
|
585
|
+
options: SyncAwaitBootstrapOptions = {}
|
|
586
|
+
): Promise<SyncProgress> {
|
|
587
|
+
const timeoutMs = Math.max(
|
|
588
|
+
0,
|
|
589
|
+
options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS
|
|
590
|
+
);
|
|
591
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
592
|
+
const deadline = Date.now() + timeoutMs;
|
|
593
|
+
|
|
594
|
+
while (true) {
|
|
595
|
+
const states = await this.listSubscriptionStates({ stateId });
|
|
596
|
+
const relevantStates =
|
|
597
|
+
options.subscriptionId === undefined
|
|
598
|
+
? states
|
|
599
|
+
: states.filter(
|
|
600
|
+
(state) => state.subscriptionId === options.subscriptionId
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const hasPendingBootstrap = relevantStates.some(
|
|
604
|
+
(state) => state.status === 'active' && state.bootstrapState !== null
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
if (!hasPendingBootstrap) {
|
|
608
|
+
return this.getProgress();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (this.state.error) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const remainingMs = deadline - Date.now();
|
|
618
|
+
if (remainingMs <= 0) {
|
|
619
|
+
const target =
|
|
620
|
+
options.subscriptionId === undefined
|
|
621
|
+
? `state "${stateId}"`
|
|
622
|
+
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
623
|
+
|
|
624
|
+
throw new Error(
|
|
625
|
+
`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
await this.waitForProgressSignal(remainingMs);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Get a diagnostics snapshot suitable for debug UIs and bug reports.
|
|
635
|
+
*/
|
|
636
|
+
async getDiagnostics(): Promise<SyncDiagnostics> {
|
|
637
|
+
const [subscriptions, progress, outbox, conflicts] = await Promise.all([
|
|
638
|
+
this.listSubscriptionStates(),
|
|
639
|
+
this.getProgress(),
|
|
640
|
+
this.refreshOutboxStats({ emit: false }),
|
|
641
|
+
this.getConflicts(),
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
timestamp: Date.now(),
|
|
646
|
+
state: this.state,
|
|
647
|
+
transport: this.transportHealth,
|
|
648
|
+
progress,
|
|
649
|
+
outbox,
|
|
650
|
+
conflictCount: conflicts.length,
|
|
651
|
+
subscriptions,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Get a serializable inspector snapshot for app debug UIs and support tooling.
|
|
657
|
+
*/
|
|
658
|
+
async getInspectorSnapshot(
|
|
659
|
+
options: SyncInspectorOptions = {}
|
|
660
|
+
): Promise<SyncInspectorSnapshot> {
|
|
661
|
+
const diagnostics = await this.getDiagnostics();
|
|
662
|
+
const requestedLimit = options.eventLimit ?? DEFAULT_INSPECTOR_EVENT_LIMIT;
|
|
663
|
+
const eventLimit = Math.max(
|
|
664
|
+
0,
|
|
665
|
+
Math.min(MAX_INSPECTOR_EVENT_LIMIT, requestedLimit)
|
|
666
|
+
);
|
|
667
|
+
const recentEvents =
|
|
668
|
+
eventLimit === 0 ? [] : this.inspectorEvents.slice(-eventLimit);
|
|
669
|
+
|
|
670
|
+
return {
|
|
671
|
+
version: 1,
|
|
672
|
+
generatedAt: Date.now(),
|
|
673
|
+
diagnostics: serializeInspectorRecord(diagnostics),
|
|
674
|
+
recentEvents,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
311
678
|
/**
|
|
312
679
|
* Get database instance
|
|
313
680
|
*/
|
|
@@ -329,6 +696,444 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
329
696
|
return this.config.clientId;
|
|
330
697
|
}
|
|
331
698
|
|
|
699
|
+
private getStateId(): string {
|
|
700
|
+
return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private makeBootstrapKey(stateId: string, subscriptionId: string): string {
|
|
704
|
+
return `${stateId}:${subscriptionId}`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private updateTransportHealth(partial: Partial<TransportHealth>): void {
|
|
708
|
+
this.transportHealth = {
|
|
709
|
+
...this.transportHealth,
|
|
710
|
+
...partial,
|
|
711
|
+
};
|
|
712
|
+
this.emit('state:change', {});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private waitForProgressSignal(timeoutMs: number): Promise<void> {
|
|
716
|
+
return new Promise((resolve) => {
|
|
717
|
+
const cleanups: Array<() => void> = [];
|
|
718
|
+
let settled = false;
|
|
719
|
+
|
|
720
|
+
const finish = () => {
|
|
721
|
+
if (settled) return;
|
|
722
|
+
settled = true;
|
|
723
|
+
clearTimeout(timeoutId);
|
|
724
|
+
for (const cleanup of cleanups) cleanup();
|
|
725
|
+
resolve();
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const listen = (event: SyncEventType) => {
|
|
729
|
+
cleanups.push(this.on(event, finish));
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
listen('sync:start');
|
|
733
|
+
listen('sync:complete');
|
|
734
|
+
listen('sync:error');
|
|
735
|
+
listen('sync:live');
|
|
736
|
+
listen('bootstrap:start');
|
|
737
|
+
listen('bootstrap:progress');
|
|
738
|
+
listen('bootstrap:complete');
|
|
739
|
+
|
|
740
|
+
const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private mapSubscriptionToProgress(
|
|
745
|
+
subscription: SubscriptionState
|
|
746
|
+
): SubscriptionProgress {
|
|
747
|
+
if (subscription.status === 'revoked') {
|
|
748
|
+
return {
|
|
749
|
+
stateId: subscription.stateId,
|
|
750
|
+
id: subscription.subscriptionId,
|
|
751
|
+
table: subscription.table,
|
|
752
|
+
phase: 'error',
|
|
753
|
+
progressPercent: 0,
|
|
754
|
+
startedAt: subscription.createdAt,
|
|
755
|
+
completedAt: subscription.updatedAt,
|
|
756
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
757
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (subscription.bootstrapState) {
|
|
762
|
+
const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
|
|
763
|
+
const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
|
|
764
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
765
|
+
const progressPercent =
|
|
766
|
+
tableCount === 0
|
|
767
|
+
? 0
|
|
768
|
+
: Math.max(
|
|
769
|
+
0,
|
|
770
|
+
Math.min(100, Math.round((tablesProcessed / tableCount) * 100))
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
stateId: subscription.stateId,
|
|
775
|
+
id: subscription.subscriptionId,
|
|
776
|
+
table: subscription.table,
|
|
777
|
+
phase: 'bootstrapping',
|
|
778
|
+
progressPercent,
|
|
779
|
+
tablesProcessed,
|
|
780
|
+
tablesTotal: tableCount,
|
|
781
|
+
startedAt: this.bootstrapStartedAt.get(
|
|
782
|
+
this.makeBootstrapKey(
|
|
783
|
+
subscription.stateId,
|
|
784
|
+
subscription.subscriptionId
|
|
785
|
+
)
|
|
786
|
+
),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (this.state.error) {
|
|
791
|
+
return {
|
|
792
|
+
stateId: subscription.stateId,
|
|
793
|
+
id: subscription.subscriptionId,
|
|
794
|
+
table: subscription.table,
|
|
795
|
+
phase: 'error',
|
|
796
|
+
progressPercent: subscription.cursor >= 0 ? 100 : 0,
|
|
797
|
+
startedAt: subscription.createdAt,
|
|
798
|
+
lastErrorCode: this.state.error.code,
|
|
799
|
+
lastErrorMessage: this.state.error.message,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (this.state.isSyncing) {
|
|
804
|
+
return {
|
|
805
|
+
stateId: subscription.stateId,
|
|
806
|
+
id: subscription.subscriptionId,
|
|
807
|
+
table: subscription.table,
|
|
808
|
+
phase: 'catching_up',
|
|
809
|
+
progressPercent: subscription.cursor >= 0 ? 90 : 0,
|
|
810
|
+
startedAt: subscription.createdAt,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
|
|
815
|
+
return {
|
|
816
|
+
stateId: subscription.stateId,
|
|
817
|
+
id: subscription.subscriptionId,
|
|
818
|
+
table: subscription.table,
|
|
819
|
+
phase: 'live',
|
|
820
|
+
progressPercent: 100,
|
|
821
|
+
startedAt: subscription.createdAt,
|
|
822
|
+
completedAt: subscription.updatedAt,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
stateId: subscription.stateId,
|
|
828
|
+
id: subscription.subscriptionId,
|
|
829
|
+
table: subscription.table,
|
|
830
|
+
phase: 'idle',
|
|
831
|
+
progressPercent: 0,
|
|
832
|
+
startedAt: subscription.createdAt,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private resolveChannelPhase(
|
|
837
|
+
subscriptions: SubscriptionProgress[]
|
|
838
|
+
): SyncProgress['channelPhase'] {
|
|
839
|
+
if (this.state.error) return 'error';
|
|
840
|
+
if (subscriptions.some((sub) => sub.phase === 'error')) return 'error';
|
|
841
|
+
if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
|
|
842
|
+
return 'bootstrapping';
|
|
843
|
+
}
|
|
844
|
+
if (this.state.isSyncing) {
|
|
845
|
+
return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
|
|
846
|
+
}
|
|
847
|
+
if (this.state.lastSyncAt !== null) return 'live';
|
|
848
|
+
return 'idle';
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private deriveProgressFromPullSubscription(
|
|
852
|
+
sub: SyncPullSubscriptionResponse
|
|
853
|
+
): SubscriptionProgress {
|
|
854
|
+
const stateId = this.getStateId();
|
|
855
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
856
|
+
const startedAt = this.bootstrapStartedAt.get(key);
|
|
857
|
+
|
|
858
|
+
if (sub.status === 'revoked') {
|
|
859
|
+
return {
|
|
860
|
+
stateId,
|
|
861
|
+
id: sub.id,
|
|
862
|
+
phase: 'error',
|
|
863
|
+
progressPercent: 0,
|
|
864
|
+
startedAt,
|
|
865
|
+
completedAt: Date.now(),
|
|
866
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
867
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (sub.bootstrap && sub.bootstrapState) {
|
|
872
|
+
const tableCount = Math.max(0, sub.bootstrapState.tables.length);
|
|
873
|
+
const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
|
|
874
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
875
|
+
const progressPercent =
|
|
876
|
+
tableCount === 0
|
|
877
|
+
? 0
|
|
878
|
+
: Math.max(
|
|
879
|
+
0,
|
|
880
|
+
Math.min(100, Math.round((tablesProcessed / tableCount) * 100))
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
return {
|
|
884
|
+
stateId,
|
|
885
|
+
id: sub.id,
|
|
886
|
+
phase: 'bootstrapping',
|
|
887
|
+
progressPercent,
|
|
888
|
+
tablesProcessed,
|
|
889
|
+
tablesTotal: tableCount,
|
|
890
|
+
startedAt,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
stateId,
|
|
896
|
+
id: sub.id,
|
|
897
|
+
phase: this.state.isSyncing ? 'catching_up' : 'live',
|
|
898
|
+
progressPercent: this.state.isSyncing ? 90 : 100,
|
|
899
|
+
startedAt,
|
|
900
|
+
completedAt: this.state.isSyncing ? undefined : Date.now(),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private handleBootstrapLifecycle(response: SyncPullResponse): void {
|
|
905
|
+
const stateId = this.getStateId();
|
|
906
|
+
const now = Date.now();
|
|
907
|
+
const seenKeys = new Set<string>();
|
|
908
|
+
|
|
909
|
+
for (const sub of response.subscriptions ?? []) {
|
|
910
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
911
|
+
seenKeys.add(key);
|
|
912
|
+
const isBootstrapping = sub.bootstrap === true;
|
|
913
|
+
const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
|
|
914
|
+
|
|
915
|
+
if (isBootstrapping && !wasBootstrapping) {
|
|
916
|
+
this.activeBootstrapSubscriptions.add(key);
|
|
917
|
+
this.bootstrapStartedAt.set(key, now);
|
|
918
|
+
this.emit('bootstrap:start', {
|
|
919
|
+
timestamp: now,
|
|
920
|
+
stateId,
|
|
921
|
+
subscriptionId: sub.id,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (isBootstrapping) {
|
|
926
|
+
this.emit('bootstrap:progress', {
|
|
927
|
+
timestamp: now,
|
|
928
|
+
stateId,
|
|
929
|
+
subscriptionId: sub.id,
|
|
930
|
+
progress: this.deriveProgressFromPullSubscription(sub),
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (!isBootstrapping && wasBootstrapping) {
|
|
935
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
936
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
937
|
+
this.bootstrapStartedAt.delete(key);
|
|
938
|
+
this.emit('bootstrap:complete', {
|
|
939
|
+
timestamp: now,
|
|
940
|
+
stateId,
|
|
941
|
+
subscriptionId: sub.id,
|
|
942
|
+
durationMs: Math.max(0, now - startedAt),
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
for (const key of Array.from(this.activeBootstrapSubscriptions)) {
|
|
948
|
+
if (seenKeys.has(key)) continue;
|
|
949
|
+
if (!key.startsWith(`${stateId}:`)) continue;
|
|
950
|
+
const subscriptionId = key.slice(stateId.length + 1);
|
|
951
|
+
if (!subscriptionId) continue;
|
|
952
|
+
|
|
953
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
954
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
955
|
+
this.bootstrapStartedAt.delete(key);
|
|
956
|
+
this.emit('bootstrap:complete', {
|
|
957
|
+
timestamp: now,
|
|
958
|
+
stateId,
|
|
959
|
+
subscriptionId,
|
|
960
|
+
durationMs: Math.max(0, now - startedAt),
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
|
|
965
|
+
this.emit('sync:live', { timestamp: now });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private async resolveResetTargets(
|
|
970
|
+
options: SyncResetOptions
|
|
971
|
+
): Promise<SubscriptionState[]> {
|
|
972
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
973
|
+
|
|
974
|
+
if (options.scope === 'all') {
|
|
975
|
+
return readSubscriptionStates(this.config.db);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (options.scope === 'state') {
|
|
979
|
+
return readSubscriptionStates(this.config.db, { stateId });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const subscriptionIds = options.subscriptionIds ?? [];
|
|
983
|
+
if (subscriptionIds.length === 0) {
|
|
984
|
+
throw new Error(
|
|
985
|
+
'[SyncEngine.reset] subscriptionIds is required when scope="subscription"'
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const allInState = await readSubscriptionStates(this.config.db, {
|
|
990
|
+
stateId,
|
|
991
|
+
});
|
|
992
|
+
const wanted = new Set(subscriptionIds);
|
|
993
|
+
return allInState.filter((state) => wanted.has(state.subscriptionId));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private async clearSyncedTablesForReset(
|
|
997
|
+
trx: Transaction<DB>,
|
|
998
|
+
options: SyncResetOptions,
|
|
999
|
+
targets: SubscriptionState[]
|
|
1000
|
+
): Promise<string[]> {
|
|
1001
|
+
const clearedTables: string[] = [];
|
|
1002
|
+
|
|
1003
|
+
if (!options.clearSyncedTables) {
|
|
1004
|
+
return clearedTables;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (options.scope === 'all') {
|
|
1008
|
+
for (const handler of this.config.handlers.getAll()) {
|
|
1009
|
+
await handler.clearAll({ trx, scopes: {} });
|
|
1010
|
+
clearedTables.push(handler.table);
|
|
1011
|
+
}
|
|
1012
|
+
return clearedTables;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const seen = new Set<string>();
|
|
1016
|
+
for (const target of targets) {
|
|
1017
|
+
const handler = this.config.handlers.get(target.table);
|
|
1018
|
+
if (!handler) continue;
|
|
1019
|
+
|
|
1020
|
+
const key = `${target.table}:${JSON.stringify(target.scopes)}`;
|
|
1021
|
+
if (seen.has(key)) continue;
|
|
1022
|
+
seen.add(key);
|
|
1023
|
+
|
|
1024
|
+
await handler.clearAll({ trx, scopes: target.scopes });
|
|
1025
|
+
clearedTables.push(target.table);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return clearedTables;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
async reset(options: SyncResetOptions): Promise<SyncResetResult> {
|
|
1032
|
+
const resetOptions: SyncResetOptions = {
|
|
1033
|
+
clearOutbox: false,
|
|
1034
|
+
clearConflicts: false,
|
|
1035
|
+
clearSyncedTables: false,
|
|
1036
|
+
...options,
|
|
1037
|
+
};
|
|
1038
|
+
const targets = await this.resolveResetTargets(resetOptions);
|
|
1039
|
+
const stateId = resetOptions.stateId ?? this.getStateId();
|
|
1040
|
+
|
|
1041
|
+
this.stop();
|
|
1042
|
+
|
|
1043
|
+
const result = await this.config.db.transaction().execute(async (trx) => {
|
|
1044
|
+
const clearedTables = await this.clearSyncedTablesForReset(
|
|
1045
|
+
trx,
|
|
1046
|
+
resetOptions,
|
|
1047
|
+
targets
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
let deletedSubscriptionStates = 0;
|
|
1051
|
+
if (resetOptions.scope === 'all') {
|
|
1052
|
+
const res = await sql`
|
|
1053
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1054
|
+
`.execute(trx);
|
|
1055
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
1056
|
+
} else if (resetOptions.scope === 'state') {
|
|
1057
|
+
const res = await sql`
|
|
1058
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1059
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
1060
|
+
`.execute(trx);
|
|
1061
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
1062
|
+
} else {
|
|
1063
|
+
const subscriptionIds = resetOptions.subscriptionIds ?? [];
|
|
1064
|
+
const res = await sql`
|
|
1065
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
1066
|
+
where
|
|
1067
|
+
${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
1068
|
+
and ${sql.ref('subscription_id')} in (${sql.join(
|
|
1069
|
+
subscriptionIds.map((id) => sql.val(id))
|
|
1070
|
+
)})
|
|
1071
|
+
`.execute(trx);
|
|
1072
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
let deletedOutboxCommits = 0;
|
|
1076
|
+
if (resetOptions.clearOutbox) {
|
|
1077
|
+
const res = await sql`
|
|
1078
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
1079
|
+
`.execute(trx);
|
|
1080
|
+
deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
let deletedConflicts = 0;
|
|
1084
|
+
if (resetOptions.clearConflicts) {
|
|
1085
|
+
const res = await sql`
|
|
1086
|
+
delete from ${sql.table('sync_conflicts')}
|
|
1087
|
+
`.execute(trx);
|
|
1088
|
+
deletedConflicts = Number(res.numAffectedRows ?? 0);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
deletedSubscriptionStates,
|
|
1093
|
+
deletedOutboxCommits,
|
|
1094
|
+
deletedConflicts,
|
|
1095
|
+
clearedTables,
|
|
1096
|
+
};
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
if (resetOptions.scope === 'all') {
|
|
1100
|
+
this.activeBootstrapSubscriptions.clear();
|
|
1101
|
+
this.bootstrapStartedAt.clear();
|
|
1102
|
+
} else {
|
|
1103
|
+
for (const target of targets) {
|
|
1104
|
+
const key = this.makeBootstrapKey(
|
|
1105
|
+
target.stateId,
|
|
1106
|
+
target.subscriptionId
|
|
1107
|
+
);
|
|
1108
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
1109
|
+
this.bootstrapStartedAt.delete(key);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
this.resetLocalState();
|
|
1114
|
+
await this.refreshOutboxStats();
|
|
1115
|
+
this.updateState({ error: null });
|
|
1116
|
+
|
|
1117
|
+
return result;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async repair(options: SyncRepairOptions): Promise<SyncResetResult> {
|
|
1121
|
+
if (options.mode !== 'rebootstrap-missing-chunks') {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return this.reset({
|
|
1128
|
+
scope: options.subscriptionIds ? 'subscription' : 'state',
|
|
1129
|
+
stateId: options.stateId,
|
|
1130
|
+
subscriptionIds: options.subscriptionIds,
|
|
1131
|
+
clearOutbox: options.clearOutbox ?? false,
|
|
1132
|
+
clearConflicts: options.clearConflicts ?? false,
|
|
1133
|
+
clearSyncedTables: true,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
332
1137
|
/**
|
|
333
1138
|
* Subscribe to sync events
|
|
334
1139
|
*/
|
|
@@ -361,6 +1166,19 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
361
1166
|
event: T,
|
|
362
1167
|
payload: SyncEventPayloads[T]
|
|
363
1168
|
): void {
|
|
1169
|
+
this.inspectorEvents.push({
|
|
1170
|
+
id: this.nextInspectorEventId++,
|
|
1171
|
+
event,
|
|
1172
|
+
timestamp: Date.now(),
|
|
1173
|
+
payload: serializeInspectorRecord(payload),
|
|
1174
|
+
});
|
|
1175
|
+
if (this.inspectorEvents.length > MAX_INSPECTOR_EVENT_LIMIT) {
|
|
1176
|
+
this.inspectorEvents.splice(
|
|
1177
|
+
0,
|
|
1178
|
+
this.inspectorEvents.length - MAX_INSPECTOR_EVENT_LIMIT
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
364
1182
|
const eventListeners = this.listeners.get(event);
|
|
365
1183
|
if (eventListeners) {
|
|
366
1184
|
for (const listener of eventListeners) {
|
|
@@ -441,11 +1259,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
441
1259
|
const migrationError =
|
|
442
1260
|
err instanceof Error ? err : new Error(String(err));
|
|
443
1261
|
this.config.onMigrationError?.(migrationError);
|
|
444
|
-
const error = createSyncError(
|
|
445
|
-
'
|
|
446
|
-
'Migration failed',
|
|
447
|
-
migrationError
|
|
448
|
-
|
|
1262
|
+
const error = createSyncError({
|
|
1263
|
+
code: 'MIGRATION_FAILED',
|
|
1264
|
+
message: 'Migration failed',
|
|
1265
|
+
cause: migrationError,
|
|
1266
|
+
retryable: false,
|
|
1267
|
+
stateId: this.getStateId(),
|
|
1268
|
+
});
|
|
1269
|
+
this.updateState({
|
|
1270
|
+
isSyncing: false,
|
|
1271
|
+
error,
|
|
1272
|
+
});
|
|
449
1273
|
this.handleError(error);
|
|
450
1274
|
return;
|
|
451
1275
|
}
|
|
@@ -513,7 +1337,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
513
1337
|
pushedCommits: 0,
|
|
514
1338
|
pullRounds: 0,
|
|
515
1339
|
pullResponse: { ok: true, subscriptions: [] },
|
|
516
|
-
error: createSyncError(
|
|
1340
|
+
error: createSyncError({
|
|
1341
|
+
code: 'SYNC_ERROR',
|
|
1342
|
+
message: 'Sync not enabled',
|
|
1343
|
+
retryable: false,
|
|
1344
|
+
stateId: this.getStateId(),
|
|
1345
|
+
}),
|
|
517
1346
|
};
|
|
518
1347
|
}
|
|
519
1348
|
|
|
@@ -533,7 +1362,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
533
1362
|
pushedCommits: 0,
|
|
534
1363
|
pullRounds: 0,
|
|
535
1364
|
pullResponse: { ok: true, subscriptions: [] },
|
|
536
|
-
error: createSyncError(
|
|
1365
|
+
error: createSyncError({
|
|
1366
|
+
code: 'SYNC_ERROR',
|
|
1367
|
+
message: 'Sync not started',
|
|
1368
|
+
retryable: false,
|
|
1369
|
+
stateId: this.getStateId(),
|
|
1370
|
+
}),
|
|
537
1371
|
};
|
|
538
1372
|
|
|
539
1373
|
do {
|
|
@@ -614,6 +1448,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
614
1448
|
retryCount: 0,
|
|
615
1449
|
isRetrying: false,
|
|
616
1450
|
});
|
|
1451
|
+
this.updateTransportHealth({
|
|
1452
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1453
|
+
});
|
|
617
1454
|
|
|
618
1455
|
this.emit('sync:complete', {
|
|
619
1456
|
timestamp: Date.now(),
|
|
@@ -631,6 +1468,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
631
1468
|
});
|
|
632
1469
|
this.config.onDataChange?.(changedTables);
|
|
633
1470
|
}
|
|
1471
|
+
this.handleBootstrapLifecycle(result.pullResponse);
|
|
634
1472
|
|
|
635
1473
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
636
1474
|
this.refreshOutboxStats().catch((error) => {
|
|
@@ -671,11 +1509,15 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
671
1509
|
|
|
672
1510
|
return syncResult;
|
|
673
1511
|
} catch (err) {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1512
|
+
const classified = classifySyncFailure(err);
|
|
1513
|
+
const error = createSyncError({
|
|
1514
|
+
code: classified.code,
|
|
1515
|
+
message: classified.message,
|
|
1516
|
+
cause: classified.cause,
|
|
1517
|
+
retryable: classified.retryable,
|
|
1518
|
+
httpStatus: classified.httpStatus,
|
|
1519
|
+
stateId: this.getStateId(),
|
|
1520
|
+
});
|
|
679
1521
|
|
|
680
1522
|
this.updateState({
|
|
681
1523
|
isSyncing: false,
|
|
@@ -707,7 +1549,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
707
1549
|
|
|
708
1550
|
// Schedule retry if under max retries
|
|
709
1551
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
710
|
-
if (this.state.retryCount < maxRetries) {
|
|
1552
|
+
if (error.retryable && this.state.retryCount < maxRetries) {
|
|
711
1553
|
this.scheduleRetry();
|
|
712
1554
|
}
|
|
713
1555
|
|
|
@@ -886,6 +1728,12 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
886
1728
|
retryCount: 0,
|
|
887
1729
|
isRetrying: false,
|
|
888
1730
|
});
|
|
1731
|
+
this.updateTransportHealth({
|
|
1732
|
+
mode: 'realtime',
|
|
1733
|
+
connected: true,
|
|
1734
|
+
fallbackReason: null,
|
|
1735
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1736
|
+
});
|
|
889
1737
|
|
|
890
1738
|
this.emit('sync:complete', {
|
|
891
1739
|
timestamp: Date.now(),
|
|
@@ -893,6 +1741,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
893
1741
|
pullRounds: 0,
|
|
894
1742
|
pullResponse: { ok: true, subscriptions: [] },
|
|
895
1743
|
});
|
|
1744
|
+
this.emit('sync:live', { timestamp: Date.now() });
|
|
896
1745
|
|
|
897
1746
|
this.refreshOutboxStats().catch((error) => {
|
|
898
1747
|
console.warn(
|
|
@@ -1041,6 +1890,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1041
1890
|
}, interval);
|
|
1042
1891
|
|
|
1043
1892
|
this.setConnectionState('connected');
|
|
1893
|
+
this.updateTransportHealth({
|
|
1894
|
+
mode: 'polling',
|
|
1895
|
+
connected: true,
|
|
1896
|
+
fallbackReason: null,
|
|
1897
|
+
});
|
|
1044
1898
|
}
|
|
1045
1899
|
|
|
1046
1900
|
private stopPolling(): void {
|
|
@@ -1061,6 +1915,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1061
1915
|
}
|
|
1062
1916
|
|
|
1063
1917
|
this.setConnectionState('connecting');
|
|
1918
|
+
this.updateTransportHealth({
|
|
1919
|
+
mode: 'disconnected',
|
|
1920
|
+
connected: false,
|
|
1921
|
+
fallbackReason: null,
|
|
1922
|
+
});
|
|
1064
1923
|
|
|
1065
1924
|
const transport = this.config.transport as RealtimeTransportLike;
|
|
1066
1925
|
|
|
@@ -1089,6 +1948,9 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1089
1948
|
{ clientId: this.config.clientId! },
|
|
1090
1949
|
(event) => {
|
|
1091
1950
|
if (event.event === 'sync') {
|
|
1951
|
+
this.updateTransportHealth({
|
|
1952
|
+
lastRealtimeMessageAt: Date.now(),
|
|
1953
|
+
});
|
|
1092
1954
|
countSyncMetric('sync.client.ws.events', 1, {
|
|
1093
1955
|
attributes: { type: 'sync' },
|
|
1094
1956
|
});
|
|
@@ -1114,6 +1976,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1114
1976
|
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
1115
1977
|
this.hasRealtimeConnectedOnce = true;
|
|
1116
1978
|
this.setConnectionState('connected');
|
|
1979
|
+
this.updateTransportHealth({
|
|
1980
|
+
mode: 'realtime',
|
|
1981
|
+
connected: true,
|
|
1982
|
+
fallbackReason: null,
|
|
1983
|
+
});
|
|
1117
1984
|
this.stopFallbackPolling();
|
|
1118
1985
|
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
1119
1986
|
if (wasConnectedBefore) {
|
|
@@ -1123,9 +1990,17 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1123
1990
|
}
|
|
1124
1991
|
case 'connecting':
|
|
1125
1992
|
this.setConnectionState('connecting');
|
|
1993
|
+
this.updateTransportHealth({
|
|
1994
|
+
mode: 'disconnected',
|
|
1995
|
+
connected: false,
|
|
1996
|
+
});
|
|
1126
1997
|
break;
|
|
1127
1998
|
case 'disconnected':
|
|
1128
1999
|
this.setConnectionState('reconnecting');
|
|
2000
|
+
this.updateTransportHealth({
|
|
2001
|
+
mode: 'disconnected',
|
|
2002
|
+
connected: false,
|
|
2003
|
+
});
|
|
1129
2004
|
this.startFallbackPolling();
|
|
1130
2005
|
break;
|
|
1131
2006
|
}
|
|
@@ -1147,6 +2022,10 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1147
2022
|
this.realtimeDisconnect = null;
|
|
1148
2023
|
}
|
|
1149
2024
|
this.stopFallbackPolling();
|
|
2025
|
+
this.updateTransportHealth({
|
|
2026
|
+
mode: 'disconnected',
|
|
2027
|
+
connected: false,
|
|
2028
|
+
});
|
|
1150
2029
|
}
|
|
1151
2030
|
|
|
1152
2031
|
private scheduleRealtimeReconnectCatchupSync(): void {
|
|
@@ -1168,6 +2047,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1168
2047
|
if (this.fallbackPollerId) return;
|
|
1169
2048
|
|
|
1170
2049
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
2050
|
+
this.updateTransportHealth({
|
|
2051
|
+
mode: 'polling',
|
|
2052
|
+
connected: false,
|
|
2053
|
+
fallbackReason: 'network',
|
|
2054
|
+
});
|
|
1171
2055
|
this.fallbackPollerId = setInterval(() => {
|
|
1172
2056
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
1173
2057
|
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
@@ -1180,6 +2064,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
|
|
|
1180
2064
|
clearInterval(this.fallbackPollerId);
|
|
1181
2065
|
this.fallbackPollerId = null;
|
|
1182
2066
|
}
|
|
2067
|
+
this.updateTransportHealth({ fallbackReason: null });
|
|
1183
2068
|
}
|
|
1184
2069
|
|
|
1185
2070
|
/**
|