@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
|
@@ -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,7 @@ 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;
|
|
17
19
|
function calculateRetryDelay(attemptIndex) {
|
|
18
20
|
return Math.min(INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex, MAX_RETRY_DELAY_MS);
|
|
19
21
|
}
|
|
@@ -22,12 +24,85 @@ function isRealtimeTransport(transport) {
|
|
|
22
24
|
transport !== null &&
|
|
23
25
|
typeof transport.connect === 'function');
|
|
24
26
|
}
|
|
25
|
-
function createSyncError(
|
|
27
|
+
function createSyncError(args) {
|
|
26
28
|
return {
|
|
27
|
-
code,
|
|
29
|
+
code: args.code,
|
|
30
|
+
message: args.message,
|
|
31
|
+
cause: args.cause,
|
|
32
|
+
timestamp: Date.now(),
|
|
33
|
+
retryable: args.retryable ?? false,
|
|
34
|
+
httpStatus: args.httpStatus,
|
|
35
|
+
subscriptionId: args.subscriptionId,
|
|
36
|
+
stateId: args.stateId,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function classifySyncFailure(error) {
|
|
40
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
41
|
+
const message = cause.message || 'Sync failed';
|
|
42
|
+
const normalized = message.toLowerCase();
|
|
43
|
+
if (cause instanceof SyncTransportError) {
|
|
44
|
+
if (cause.status === 401 || cause.status === 403) {
|
|
45
|
+
return {
|
|
46
|
+
code: 'AUTH_FAILED',
|
|
47
|
+
message,
|
|
48
|
+
cause,
|
|
49
|
+
retryable: false,
|
|
50
|
+
httpStatus: cause.status,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (cause.status === 404 &&
|
|
54
|
+
normalized.includes('snapshot') &&
|
|
55
|
+
normalized.includes('chunk')) {
|
|
56
|
+
return {
|
|
57
|
+
code: 'SNAPSHOT_CHUNK_NOT_FOUND',
|
|
58
|
+
message,
|
|
59
|
+
cause,
|
|
60
|
+
retryable: false,
|
|
61
|
+
httpStatus: cause.status,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (cause.status !== undefined &&
|
|
65
|
+
(cause.status >= 500 || cause.status === 408 || cause.status === 429)) {
|
|
66
|
+
return {
|
|
67
|
+
code: 'NETWORK_ERROR',
|
|
68
|
+
message,
|
|
69
|
+
cause,
|
|
70
|
+
retryable: true,
|
|
71
|
+
httpStatus: cause.status,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
code: 'SYNC_ERROR',
|
|
76
|
+
message,
|
|
77
|
+
cause,
|
|
78
|
+
retryable: false,
|
|
79
|
+
httpStatus: cause.status,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (normalized.includes('network') ||
|
|
83
|
+
normalized.includes('fetch') ||
|
|
84
|
+
normalized.includes('timeout') ||
|
|
85
|
+
normalized.includes('offline')) {
|
|
86
|
+
return {
|
|
87
|
+
code: 'NETWORK_ERROR',
|
|
88
|
+
message,
|
|
89
|
+
cause,
|
|
90
|
+
retryable: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (normalized.includes('conflict')) {
|
|
94
|
+
return {
|
|
95
|
+
code: 'CONFLICT',
|
|
96
|
+
message,
|
|
97
|
+
cause,
|
|
98
|
+
retryable: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
code: 'SYNC_ERROR',
|
|
28
103
|
message,
|
|
29
104
|
cause,
|
|
30
|
-
|
|
105
|
+
retryable: false,
|
|
31
106
|
};
|
|
32
107
|
}
|
|
33
108
|
function resolveSyncTriggerLabel(trigger) {
|
|
@@ -48,6 +123,15 @@ export class SyncEngine {
|
|
|
48
123
|
retryTimeoutId = null;
|
|
49
124
|
realtimeCatchupTimeoutId = null;
|
|
50
125
|
hasRealtimeConnectedOnce = false;
|
|
126
|
+
transportHealth = {
|
|
127
|
+
mode: 'disconnected',
|
|
128
|
+
connected: false,
|
|
129
|
+
lastSuccessfulPollAt: null,
|
|
130
|
+
lastRealtimeMessageAt: null,
|
|
131
|
+
fallbackReason: null,
|
|
132
|
+
};
|
|
133
|
+
activeBootstrapSubscriptions = new Set();
|
|
134
|
+
bootstrapStartedAt = new Map();
|
|
51
135
|
/**
|
|
52
136
|
* In-memory map tracking local mutation timestamps by rowId.
|
|
53
137
|
* Used for efficient fingerprint-based rerender optimization.
|
|
@@ -69,6 +153,13 @@ export class SyncEngine {
|
|
|
69
153
|
this.config = config;
|
|
70
154
|
this.listeners = new Map();
|
|
71
155
|
this.state = this.createInitialState();
|
|
156
|
+
this.transportHealth = {
|
|
157
|
+
mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
|
|
158
|
+
connected: false,
|
|
159
|
+
lastSuccessfulPollAt: null,
|
|
160
|
+
lastRealtimeMessageAt: null,
|
|
161
|
+
fallbackReason: null,
|
|
162
|
+
};
|
|
72
163
|
}
|
|
73
164
|
/**
|
|
74
165
|
* Get mutation timestamp for a row (used by query hooks for fingerprinting).
|
|
@@ -212,6 +303,125 @@ export class SyncEngine {
|
|
|
212
303
|
getState() {
|
|
213
304
|
return this.state;
|
|
214
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Get transport health details (realtime/polling/fallback).
|
|
308
|
+
*/
|
|
309
|
+
getTransportHealth() {
|
|
310
|
+
return this.transportHealth;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get subscription state metadata for the current profile.
|
|
314
|
+
*/
|
|
315
|
+
async listSubscriptionStates(args) {
|
|
316
|
+
return readSubscriptionStates(this.config.db, {
|
|
317
|
+
stateId: args?.stateId ?? this.getStateId(),
|
|
318
|
+
table: args?.table,
|
|
319
|
+
status: args?.status,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Get a single subscription state by id.
|
|
324
|
+
*/
|
|
325
|
+
async getSubscriptionState(subscriptionId, options) {
|
|
326
|
+
return readSubscriptionState(this.config.db, {
|
|
327
|
+
stateId: options?.stateId ?? this.getStateId(),
|
|
328
|
+
subscriptionId,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get normalized progress for all active subscriptions in this state profile.
|
|
333
|
+
*/
|
|
334
|
+
async getProgress() {
|
|
335
|
+
const subscriptions = await this.listSubscriptionStates();
|
|
336
|
+
const progress = subscriptions.map((sub) => this.mapSubscriptionToProgress(sub));
|
|
337
|
+
const channelPhase = this.resolveChannelPhase(progress);
|
|
338
|
+
const hasSubscriptions = progress.length > 0;
|
|
339
|
+
const basePercent = hasSubscriptions
|
|
340
|
+
? Math.round(progress.reduce((sum, item) => sum + item.progressPercent, 0) /
|
|
341
|
+
progress.length)
|
|
342
|
+
: this.state.lastSyncAt !== null
|
|
343
|
+
? 100
|
|
344
|
+
: 0;
|
|
345
|
+
const progressPercent = channelPhase === 'live'
|
|
346
|
+
? 100
|
|
347
|
+
: Math.max(0, Math.min(100, Math.trunc(basePercent)));
|
|
348
|
+
return {
|
|
349
|
+
channelPhase,
|
|
350
|
+
progressPercent,
|
|
351
|
+
subscriptions: progress,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Wait until the channel reaches a target phase.
|
|
356
|
+
*/
|
|
357
|
+
async awaitPhase(phase, options = {}) {
|
|
358
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
|
|
359
|
+
const deadline = Date.now() + timeoutMs;
|
|
360
|
+
while (true) {
|
|
361
|
+
const progress = await this.getProgress();
|
|
362
|
+
if (progress.channelPhase === phase) {
|
|
363
|
+
return progress;
|
|
364
|
+
}
|
|
365
|
+
if (progress.channelPhase === 'error') {
|
|
366
|
+
const message = this.state.error?.message ?? 'Sync entered error state';
|
|
367
|
+
throw new Error(`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`);
|
|
368
|
+
}
|
|
369
|
+
const remainingMs = deadline - Date.now();
|
|
370
|
+
if (remainingMs <= 0) {
|
|
371
|
+
throw new Error(`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`);
|
|
372
|
+
}
|
|
373
|
+
await this.waitForProgressSignal(remainingMs);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Wait until bootstrap finishes for a state or a specific subscription.
|
|
378
|
+
*/
|
|
379
|
+
async awaitBootstrapComplete(options = {}) {
|
|
380
|
+
const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
|
|
381
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
382
|
+
const deadline = Date.now() + timeoutMs;
|
|
383
|
+
while (true) {
|
|
384
|
+
const states = await this.listSubscriptionStates({ stateId });
|
|
385
|
+
const relevantStates = options.subscriptionId === undefined
|
|
386
|
+
? states
|
|
387
|
+
: states.filter((state) => state.subscriptionId === options.subscriptionId);
|
|
388
|
+
const hasPendingBootstrap = relevantStates.some((state) => state.status === 'active' && state.bootstrapState !== null);
|
|
389
|
+
if (!hasPendingBootstrap) {
|
|
390
|
+
return this.getProgress();
|
|
391
|
+
}
|
|
392
|
+
if (this.state.error) {
|
|
393
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`);
|
|
394
|
+
}
|
|
395
|
+
const remainingMs = deadline - Date.now();
|
|
396
|
+
if (remainingMs <= 0) {
|
|
397
|
+
const target = options.subscriptionId === undefined
|
|
398
|
+
? `state "${stateId}"`
|
|
399
|
+
: `subscription "${options.subscriptionId}" in state "${stateId}"`;
|
|
400
|
+
throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`);
|
|
401
|
+
}
|
|
402
|
+
await this.waitForProgressSignal(remainingMs);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Get a diagnostics snapshot suitable for debug UIs and bug reports.
|
|
407
|
+
*/
|
|
408
|
+
async getDiagnostics() {
|
|
409
|
+
const [subscriptions, progress, outbox, conflicts] = await Promise.all([
|
|
410
|
+
this.listSubscriptionStates(),
|
|
411
|
+
this.getProgress(),
|
|
412
|
+
this.refreshOutboxStats({ emit: false }),
|
|
413
|
+
this.getConflicts(),
|
|
414
|
+
]);
|
|
415
|
+
return {
|
|
416
|
+
timestamp: Date.now(),
|
|
417
|
+
state: this.state,
|
|
418
|
+
transport: this.transportHealth,
|
|
419
|
+
progress,
|
|
420
|
+
outbox,
|
|
421
|
+
conflictCount: conflicts.length,
|
|
422
|
+
subscriptions,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
215
425
|
/**
|
|
216
426
|
* Get database instance
|
|
217
427
|
*/
|
|
@@ -230,6 +440,366 @@ export class SyncEngine {
|
|
|
230
440
|
getClientId() {
|
|
231
441
|
return this.config.clientId;
|
|
232
442
|
}
|
|
443
|
+
getStateId() {
|
|
444
|
+
return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
|
|
445
|
+
}
|
|
446
|
+
makeBootstrapKey(stateId, subscriptionId) {
|
|
447
|
+
return `${stateId}:${subscriptionId}`;
|
|
448
|
+
}
|
|
449
|
+
updateTransportHealth(partial) {
|
|
450
|
+
this.transportHealth = {
|
|
451
|
+
...this.transportHealth,
|
|
452
|
+
...partial,
|
|
453
|
+
};
|
|
454
|
+
this.emit('state:change', {});
|
|
455
|
+
}
|
|
456
|
+
waitForProgressSignal(timeoutMs) {
|
|
457
|
+
return new Promise((resolve) => {
|
|
458
|
+
const cleanups = [];
|
|
459
|
+
let settled = false;
|
|
460
|
+
const finish = () => {
|
|
461
|
+
if (settled)
|
|
462
|
+
return;
|
|
463
|
+
settled = true;
|
|
464
|
+
clearTimeout(timeoutId);
|
|
465
|
+
for (const cleanup of cleanups)
|
|
466
|
+
cleanup();
|
|
467
|
+
resolve();
|
|
468
|
+
};
|
|
469
|
+
const listen = (event) => {
|
|
470
|
+
cleanups.push(this.on(event, finish));
|
|
471
|
+
};
|
|
472
|
+
listen('sync:start');
|
|
473
|
+
listen('sync:complete');
|
|
474
|
+
listen('sync:error');
|
|
475
|
+
listen('sync:live');
|
|
476
|
+
listen('bootstrap:start');
|
|
477
|
+
listen('bootstrap:progress');
|
|
478
|
+
listen('bootstrap:complete');
|
|
479
|
+
const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
mapSubscriptionToProgress(subscription) {
|
|
483
|
+
if (subscription.status === 'revoked') {
|
|
484
|
+
return {
|
|
485
|
+
stateId: subscription.stateId,
|
|
486
|
+
id: subscription.subscriptionId,
|
|
487
|
+
table: subscription.table,
|
|
488
|
+
phase: 'error',
|
|
489
|
+
progressPercent: 0,
|
|
490
|
+
startedAt: subscription.createdAt,
|
|
491
|
+
completedAt: subscription.updatedAt,
|
|
492
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
493
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
if (subscription.bootstrapState) {
|
|
497
|
+
const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
|
|
498
|
+
const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
|
|
499
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
500
|
+
const progressPercent = tableCount === 0
|
|
501
|
+
? 0
|
|
502
|
+
: Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
|
|
503
|
+
return {
|
|
504
|
+
stateId: subscription.stateId,
|
|
505
|
+
id: subscription.subscriptionId,
|
|
506
|
+
table: subscription.table,
|
|
507
|
+
phase: 'bootstrapping',
|
|
508
|
+
progressPercent,
|
|
509
|
+
tablesProcessed,
|
|
510
|
+
tablesTotal: tableCount,
|
|
511
|
+
startedAt: this.bootstrapStartedAt.get(this.makeBootstrapKey(subscription.stateId, subscription.subscriptionId)),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (this.state.error) {
|
|
515
|
+
return {
|
|
516
|
+
stateId: subscription.stateId,
|
|
517
|
+
id: subscription.subscriptionId,
|
|
518
|
+
table: subscription.table,
|
|
519
|
+
phase: 'error',
|
|
520
|
+
progressPercent: subscription.cursor >= 0 ? 100 : 0,
|
|
521
|
+
startedAt: subscription.createdAt,
|
|
522
|
+
lastErrorCode: this.state.error.code,
|
|
523
|
+
lastErrorMessage: this.state.error.message,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (this.state.isSyncing) {
|
|
527
|
+
return {
|
|
528
|
+
stateId: subscription.stateId,
|
|
529
|
+
id: subscription.subscriptionId,
|
|
530
|
+
table: subscription.table,
|
|
531
|
+
phase: 'catching_up',
|
|
532
|
+
progressPercent: subscription.cursor >= 0 ? 90 : 0,
|
|
533
|
+
startedAt: subscription.createdAt,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
|
|
537
|
+
return {
|
|
538
|
+
stateId: subscription.stateId,
|
|
539
|
+
id: subscription.subscriptionId,
|
|
540
|
+
table: subscription.table,
|
|
541
|
+
phase: 'live',
|
|
542
|
+
progressPercent: 100,
|
|
543
|
+
startedAt: subscription.createdAt,
|
|
544
|
+
completedAt: subscription.updatedAt,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
stateId: subscription.stateId,
|
|
549
|
+
id: subscription.subscriptionId,
|
|
550
|
+
table: subscription.table,
|
|
551
|
+
phase: 'idle',
|
|
552
|
+
progressPercent: 0,
|
|
553
|
+
startedAt: subscription.createdAt,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
resolveChannelPhase(subscriptions) {
|
|
557
|
+
if (this.state.error)
|
|
558
|
+
return 'error';
|
|
559
|
+
if (subscriptions.some((sub) => sub.phase === 'error'))
|
|
560
|
+
return 'error';
|
|
561
|
+
if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
|
|
562
|
+
return 'bootstrapping';
|
|
563
|
+
}
|
|
564
|
+
if (this.state.isSyncing) {
|
|
565
|
+
return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
|
|
566
|
+
}
|
|
567
|
+
if (this.state.lastSyncAt !== null)
|
|
568
|
+
return 'live';
|
|
569
|
+
return 'idle';
|
|
570
|
+
}
|
|
571
|
+
deriveProgressFromPullSubscription(sub) {
|
|
572
|
+
const stateId = this.getStateId();
|
|
573
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
574
|
+
const startedAt = this.bootstrapStartedAt.get(key);
|
|
575
|
+
if (sub.status === 'revoked') {
|
|
576
|
+
return {
|
|
577
|
+
stateId,
|
|
578
|
+
id: sub.id,
|
|
579
|
+
phase: 'error',
|
|
580
|
+
progressPercent: 0,
|
|
581
|
+
startedAt,
|
|
582
|
+
completedAt: Date.now(),
|
|
583
|
+
lastErrorCode: 'SUBSCRIPTION_REVOKED',
|
|
584
|
+
lastErrorMessage: 'Subscription is revoked',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (sub.bootstrap && sub.bootstrapState) {
|
|
588
|
+
const tableCount = Math.max(0, sub.bootstrapState.tables.length);
|
|
589
|
+
const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
|
|
590
|
+
const tablesProcessed = Math.min(tableCount, tableIndex);
|
|
591
|
+
const progressPercent = tableCount === 0
|
|
592
|
+
? 0
|
|
593
|
+
: Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
|
|
594
|
+
return {
|
|
595
|
+
stateId,
|
|
596
|
+
id: sub.id,
|
|
597
|
+
phase: 'bootstrapping',
|
|
598
|
+
progressPercent,
|
|
599
|
+
tablesProcessed,
|
|
600
|
+
tablesTotal: tableCount,
|
|
601
|
+
startedAt,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
stateId,
|
|
606
|
+
id: sub.id,
|
|
607
|
+
phase: this.state.isSyncing ? 'catching_up' : 'live',
|
|
608
|
+
progressPercent: this.state.isSyncing ? 90 : 100,
|
|
609
|
+
startedAt,
|
|
610
|
+
completedAt: this.state.isSyncing ? undefined : Date.now(),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
handleBootstrapLifecycle(response) {
|
|
614
|
+
const stateId = this.getStateId();
|
|
615
|
+
const now = Date.now();
|
|
616
|
+
const seenKeys = new Set();
|
|
617
|
+
for (const sub of response.subscriptions ?? []) {
|
|
618
|
+
const key = this.makeBootstrapKey(stateId, sub.id);
|
|
619
|
+
seenKeys.add(key);
|
|
620
|
+
const isBootstrapping = sub.bootstrap === true;
|
|
621
|
+
const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
|
|
622
|
+
if (isBootstrapping && !wasBootstrapping) {
|
|
623
|
+
this.activeBootstrapSubscriptions.add(key);
|
|
624
|
+
this.bootstrapStartedAt.set(key, now);
|
|
625
|
+
this.emit('bootstrap:start', {
|
|
626
|
+
timestamp: now,
|
|
627
|
+
stateId,
|
|
628
|
+
subscriptionId: sub.id,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (isBootstrapping) {
|
|
632
|
+
this.emit('bootstrap:progress', {
|
|
633
|
+
timestamp: now,
|
|
634
|
+
stateId,
|
|
635
|
+
subscriptionId: sub.id,
|
|
636
|
+
progress: this.deriveProgressFromPullSubscription(sub),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
if (!isBootstrapping && wasBootstrapping) {
|
|
640
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
641
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
642
|
+
this.bootstrapStartedAt.delete(key);
|
|
643
|
+
this.emit('bootstrap:complete', {
|
|
644
|
+
timestamp: now,
|
|
645
|
+
stateId,
|
|
646
|
+
subscriptionId: sub.id,
|
|
647
|
+
durationMs: Math.max(0, now - startedAt),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
for (const key of Array.from(this.activeBootstrapSubscriptions)) {
|
|
652
|
+
if (seenKeys.has(key))
|
|
653
|
+
continue;
|
|
654
|
+
if (!key.startsWith(`${stateId}:`))
|
|
655
|
+
continue;
|
|
656
|
+
const subscriptionId = key.slice(stateId.length + 1);
|
|
657
|
+
if (!subscriptionId)
|
|
658
|
+
continue;
|
|
659
|
+
const startedAt = this.bootstrapStartedAt.get(key) ?? now;
|
|
660
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
661
|
+
this.bootstrapStartedAt.delete(key);
|
|
662
|
+
this.emit('bootstrap:complete', {
|
|
663
|
+
timestamp: now,
|
|
664
|
+
stateId,
|
|
665
|
+
subscriptionId,
|
|
666
|
+
durationMs: Math.max(0, now - startedAt),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
|
|
670
|
+
this.emit('sync:live', { timestamp: now });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async resolveResetTargets(options) {
|
|
674
|
+
const stateId = options.stateId ?? this.getStateId();
|
|
675
|
+
if (options.scope === 'all') {
|
|
676
|
+
return readSubscriptionStates(this.config.db);
|
|
677
|
+
}
|
|
678
|
+
if (options.scope === 'state') {
|
|
679
|
+
return readSubscriptionStates(this.config.db, { stateId });
|
|
680
|
+
}
|
|
681
|
+
const subscriptionIds = options.subscriptionIds ?? [];
|
|
682
|
+
if (subscriptionIds.length === 0) {
|
|
683
|
+
throw new Error('[SyncEngine.reset] subscriptionIds is required when scope="subscription"');
|
|
684
|
+
}
|
|
685
|
+
const allInState = await readSubscriptionStates(this.config.db, {
|
|
686
|
+
stateId,
|
|
687
|
+
});
|
|
688
|
+
const wanted = new Set(subscriptionIds);
|
|
689
|
+
return allInState.filter((state) => wanted.has(state.subscriptionId));
|
|
690
|
+
}
|
|
691
|
+
async clearSyncedTablesForReset(trx, options, targets) {
|
|
692
|
+
const clearedTables = [];
|
|
693
|
+
if (!options.clearSyncedTables) {
|
|
694
|
+
return clearedTables;
|
|
695
|
+
}
|
|
696
|
+
if (options.scope === 'all') {
|
|
697
|
+
for (const handler of this.config.handlers.getAll()) {
|
|
698
|
+
await handler.clearAll({ trx, scopes: {} });
|
|
699
|
+
clearedTables.push(handler.table);
|
|
700
|
+
}
|
|
701
|
+
return clearedTables;
|
|
702
|
+
}
|
|
703
|
+
const seen = new Set();
|
|
704
|
+
for (const target of targets) {
|
|
705
|
+
const handler = this.config.handlers.get(target.table);
|
|
706
|
+
if (!handler)
|
|
707
|
+
continue;
|
|
708
|
+
const key = `${target.table}:${JSON.stringify(target.scopes)}`;
|
|
709
|
+
if (seen.has(key))
|
|
710
|
+
continue;
|
|
711
|
+
seen.add(key);
|
|
712
|
+
await handler.clearAll({ trx, scopes: target.scopes });
|
|
713
|
+
clearedTables.push(target.table);
|
|
714
|
+
}
|
|
715
|
+
return clearedTables;
|
|
716
|
+
}
|
|
717
|
+
async reset(options) {
|
|
718
|
+
const resetOptions = {
|
|
719
|
+
clearOutbox: false,
|
|
720
|
+
clearConflicts: false,
|
|
721
|
+
clearSyncedTables: false,
|
|
722
|
+
...options,
|
|
723
|
+
};
|
|
724
|
+
const targets = await this.resolveResetTargets(resetOptions);
|
|
725
|
+
const stateId = resetOptions.stateId ?? this.getStateId();
|
|
726
|
+
this.stop();
|
|
727
|
+
const result = await this.config.db.transaction().execute(async (trx) => {
|
|
728
|
+
const clearedTables = await this.clearSyncedTablesForReset(trx, resetOptions, targets);
|
|
729
|
+
let deletedSubscriptionStates = 0;
|
|
730
|
+
if (resetOptions.scope === 'all') {
|
|
731
|
+
const res = await sql `
|
|
732
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
733
|
+
`.execute(trx);
|
|
734
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
735
|
+
}
|
|
736
|
+
else if (resetOptions.scope === 'state') {
|
|
737
|
+
const res = await sql `
|
|
738
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
739
|
+
where ${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
740
|
+
`.execute(trx);
|
|
741
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
const subscriptionIds = resetOptions.subscriptionIds ?? [];
|
|
745
|
+
const res = await sql `
|
|
746
|
+
delete from ${sql.table('sync_subscription_state')}
|
|
747
|
+
where
|
|
748
|
+
${sql.ref('state_id')} = ${sql.val(stateId)}
|
|
749
|
+
and ${sql.ref('subscription_id')} in (${sql.join(subscriptionIds.map((id) => sql.val(id)))})
|
|
750
|
+
`.execute(trx);
|
|
751
|
+
deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
|
|
752
|
+
}
|
|
753
|
+
let deletedOutboxCommits = 0;
|
|
754
|
+
if (resetOptions.clearOutbox) {
|
|
755
|
+
const res = await sql `
|
|
756
|
+
delete from ${sql.table('sync_outbox_commits')}
|
|
757
|
+
`.execute(trx);
|
|
758
|
+
deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
|
|
759
|
+
}
|
|
760
|
+
let deletedConflicts = 0;
|
|
761
|
+
if (resetOptions.clearConflicts) {
|
|
762
|
+
const res = await sql `
|
|
763
|
+
delete from ${sql.table('sync_conflicts')}
|
|
764
|
+
`.execute(trx);
|
|
765
|
+
deletedConflicts = Number(res.numAffectedRows ?? 0);
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
deletedSubscriptionStates,
|
|
769
|
+
deletedOutboxCommits,
|
|
770
|
+
deletedConflicts,
|
|
771
|
+
clearedTables,
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
if (resetOptions.scope === 'all') {
|
|
775
|
+
this.activeBootstrapSubscriptions.clear();
|
|
776
|
+
this.bootstrapStartedAt.clear();
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
for (const target of targets) {
|
|
780
|
+
const key = this.makeBootstrapKey(target.stateId, target.subscriptionId);
|
|
781
|
+
this.activeBootstrapSubscriptions.delete(key);
|
|
782
|
+
this.bootstrapStartedAt.delete(key);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
this.resetLocalState();
|
|
786
|
+
await this.refreshOutboxStats();
|
|
787
|
+
this.updateState({ error: null });
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
async repair(options) {
|
|
791
|
+
if (options.mode !== 'rebootstrap-missing-chunks') {
|
|
792
|
+
throw new Error(`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`);
|
|
793
|
+
}
|
|
794
|
+
return this.reset({
|
|
795
|
+
scope: options.subscriptionIds ? 'subscription' : 'state',
|
|
796
|
+
stateId: options.stateId,
|
|
797
|
+
subscriptionIds: options.subscriptionIds,
|
|
798
|
+
clearOutbox: options.clearOutbox ?? false,
|
|
799
|
+
clearConflicts: options.clearConflicts ?? false,
|
|
800
|
+
clearSyncedTables: true,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
233
803
|
/**
|
|
234
804
|
* Subscribe to sync events
|
|
235
805
|
*/
|
|
@@ -323,7 +893,17 @@ export class SyncEngine {
|
|
|
323
893
|
catch (err) {
|
|
324
894
|
const migrationError = err instanceof Error ? err : new Error(String(err));
|
|
325
895
|
this.config.onMigrationError?.(migrationError);
|
|
326
|
-
const error = createSyncError(
|
|
896
|
+
const error = createSyncError({
|
|
897
|
+
code: 'MIGRATION_FAILED',
|
|
898
|
+
message: 'Migration failed',
|
|
899
|
+
cause: migrationError,
|
|
900
|
+
retryable: false,
|
|
901
|
+
stateId: this.getStateId(),
|
|
902
|
+
});
|
|
903
|
+
this.updateState({
|
|
904
|
+
isSyncing: false,
|
|
905
|
+
error,
|
|
906
|
+
});
|
|
327
907
|
this.handleError(error);
|
|
328
908
|
return;
|
|
329
909
|
}
|
|
@@ -381,7 +961,12 @@ export class SyncEngine {
|
|
|
381
961
|
pushedCommits: 0,
|
|
382
962
|
pullRounds: 0,
|
|
383
963
|
pullResponse: { ok: true, subscriptions: [] },
|
|
384
|
-
error: createSyncError(
|
|
964
|
+
error: createSyncError({
|
|
965
|
+
code: 'SYNC_ERROR',
|
|
966
|
+
message: 'Sync not enabled',
|
|
967
|
+
retryable: false,
|
|
968
|
+
stateId: this.getStateId(),
|
|
969
|
+
}),
|
|
385
970
|
};
|
|
386
971
|
}
|
|
387
972
|
this.syncPromise = this.performSyncLoop(opts?.trigger);
|
|
@@ -398,7 +983,12 @@ export class SyncEngine {
|
|
|
398
983
|
pushedCommits: 0,
|
|
399
984
|
pullRounds: 0,
|
|
400
985
|
pullResponse: { ok: true, subscriptions: [] },
|
|
401
|
-
error: createSyncError(
|
|
986
|
+
error: createSyncError({
|
|
987
|
+
code: 'SYNC_ERROR',
|
|
988
|
+
message: 'Sync not started',
|
|
989
|
+
retryable: false,
|
|
990
|
+
stateId: this.getStateId(),
|
|
991
|
+
}),
|
|
402
992
|
};
|
|
403
993
|
do {
|
|
404
994
|
this.syncRequestedWhileRunning = false;
|
|
@@ -457,6 +1047,9 @@ export class SyncEngine {
|
|
|
457
1047
|
retryCount: 0,
|
|
458
1048
|
isRetrying: false,
|
|
459
1049
|
});
|
|
1050
|
+
this.updateTransportHealth({
|
|
1051
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1052
|
+
});
|
|
460
1053
|
this.emit('sync:complete', {
|
|
461
1054
|
timestamp: Date.now(),
|
|
462
1055
|
pushedCommits: result.pushedCommits,
|
|
@@ -472,6 +1065,7 @@ export class SyncEngine {
|
|
|
472
1065
|
});
|
|
473
1066
|
this.config.onDataChange?.(changedTables);
|
|
474
1067
|
}
|
|
1068
|
+
this.handleBootstrapLifecycle(result.pullResponse);
|
|
475
1069
|
// Refresh outbox stats (fire-and-forget — don't block sync:complete)
|
|
476
1070
|
this.refreshOutboxStats().catch((error) => {
|
|
477
1071
|
console.warn('[SyncEngine] Failed to refresh outbox stats after sync:', error);
|
|
@@ -499,7 +1093,15 @@ export class SyncEngine {
|
|
|
499
1093
|
return syncResult;
|
|
500
1094
|
}
|
|
501
1095
|
catch (err) {
|
|
502
|
-
const
|
|
1096
|
+
const classified = classifySyncFailure(err);
|
|
1097
|
+
const error = createSyncError({
|
|
1098
|
+
code: classified.code,
|
|
1099
|
+
message: classified.message,
|
|
1100
|
+
cause: classified.cause,
|
|
1101
|
+
retryable: classified.retryable,
|
|
1102
|
+
httpStatus: classified.httpStatus,
|
|
1103
|
+
stateId: this.getStateId(),
|
|
1104
|
+
});
|
|
503
1105
|
this.updateState({
|
|
504
1106
|
isSyncing: false,
|
|
505
1107
|
error,
|
|
@@ -527,7 +1129,7 @@ export class SyncEngine {
|
|
|
527
1129
|
});
|
|
528
1130
|
// Schedule retry if under max retries
|
|
529
1131
|
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
530
|
-
if (this.state.retryCount < maxRetries) {
|
|
1132
|
+
if (error.retryable && this.state.retryCount < maxRetries) {
|
|
531
1133
|
this.scheduleRetry();
|
|
532
1134
|
}
|
|
533
1135
|
return {
|
|
@@ -666,12 +1268,19 @@ export class SyncEngine {
|
|
|
666
1268
|
retryCount: 0,
|
|
667
1269
|
isRetrying: false,
|
|
668
1270
|
});
|
|
1271
|
+
this.updateTransportHealth({
|
|
1272
|
+
mode: 'realtime',
|
|
1273
|
+
connected: true,
|
|
1274
|
+
fallbackReason: null,
|
|
1275
|
+
lastSuccessfulPollAt: Date.now(),
|
|
1276
|
+
});
|
|
669
1277
|
this.emit('sync:complete', {
|
|
670
1278
|
timestamp: Date.now(),
|
|
671
1279
|
pushedCommits: 0,
|
|
672
1280
|
pullRounds: 0,
|
|
673
1281
|
pullResponse: { ok: true, subscriptions: [] },
|
|
674
1282
|
});
|
|
1283
|
+
this.emit('sync:live', { timestamp: Date.now() });
|
|
675
1284
|
this.refreshOutboxStats().catch((error) => {
|
|
676
1285
|
console.warn('[SyncEngine] Failed to refresh outbox stats after WS apply:', error);
|
|
677
1286
|
});
|
|
@@ -780,6 +1389,11 @@ export class SyncEngine {
|
|
|
780
1389
|
}
|
|
781
1390
|
}, interval);
|
|
782
1391
|
this.setConnectionState('connected');
|
|
1392
|
+
this.updateTransportHealth({
|
|
1393
|
+
mode: 'polling',
|
|
1394
|
+
connected: true,
|
|
1395
|
+
fallbackReason: null,
|
|
1396
|
+
});
|
|
783
1397
|
}
|
|
784
1398
|
stopPolling() {
|
|
785
1399
|
if (this.pollerId) {
|
|
@@ -795,6 +1409,11 @@ export class SyncEngine {
|
|
|
795
1409
|
return;
|
|
796
1410
|
}
|
|
797
1411
|
this.setConnectionState('connecting');
|
|
1412
|
+
this.updateTransportHealth({
|
|
1413
|
+
mode: 'disconnected',
|
|
1414
|
+
connected: false,
|
|
1415
|
+
fallbackReason: null,
|
|
1416
|
+
});
|
|
798
1417
|
const transport = this.config.transport;
|
|
799
1418
|
// Wire up presence events if transport supports them
|
|
800
1419
|
if (transport.onPresenceEvent) {
|
|
@@ -817,6 +1436,9 @@ export class SyncEngine {
|
|
|
817
1436
|
}
|
|
818
1437
|
this.realtimeDisconnect = transport.connect({ clientId: this.config.clientId }, (event) => {
|
|
819
1438
|
if (event.event === 'sync') {
|
|
1439
|
+
this.updateTransportHealth({
|
|
1440
|
+
lastRealtimeMessageAt: Date.now(),
|
|
1441
|
+
});
|
|
820
1442
|
countSyncMetric('sync.client.ws.events', 1, {
|
|
821
1443
|
attributes: { type: 'sync' },
|
|
822
1444
|
});
|
|
@@ -840,6 +1462,11 @@ export class SyncEngine {
|
|
|
840
1462
|
const wasConnectedBefore = this.hasRealtimeConnectedOnce;
|
|
841
1463
|
this.hasRealtimeConnectedOnce = true;
|
|
842
1464
|
this.setConnectionState('connected');
|
|
1465
|
+
this.updateTransportHealth({
|
|
1466
|
+
mode: 'realtime',
|
|
1467
|
+
connected: true,
|
|
1468
|
+
fallbackReason: null,
|
|
1469
|
+
});
|
|
843
1470
|
this.stopFallbackPolling();
|
|
844
1471
|
this.triggerSyncInBackground(undefined, 'realtime connected state');
|
|
845
1472
|
if (wasConnectedBefore) {
|
|
@@ -849,9 +1476,17 @@ export class SyncEngine {
|
|
|
849
1476
|
}
|
|
850
1477
|
case 'connecting':
|
|
851
1478
|
this.setConnectionState('connecting');
|
|
1479
|
+
this.updateTransportHealth({
|
|
1480
|
+
mode: 'disconnected',
|
|
1481
|
+
connected: false,
|
|
1482
|
+
});
|
|
852
1483
|
break;
|
|
853
1484
|
case 'disconnected':
|
|
854
1485
|
this.setConnectionState('reconnecting');
|
|
1486
|
+
this.updateTransportHealth({
|
|
1487
|
+
mode: 'disconnected',
|
|
1488
|
+
connected: false,
|
|
1489
|
+
});
|
|
855
1490
|
this.startFallbackPolling();
|
|
856
1491
|
break;
|
|
857
1492
|
}
|
|
@@ -871,6 +1506,10 @@ export class SyncEngine {
|
|
|
871
1506
|
this.realtimeDisconnect = null;
|
|
872
1507
|
}
|
|
873
1508
|
this.stopFallbackPolling();
|
|
1509
|
+
this.updateTransportHealth({
|
|
1510
|
+
mode: 'disconnected',
|
|
1511
|
+
connected: false,
|
|
1512
|
+
});
|
|
874
1513
|
}
|
|
875
1514
|
scheduleRealtimeReconnectCatchupSync() {
|
|
876
1515
|
if (this.realtimeCatchupTimeoutId) {
|
|
@@ -889,6 +1528,11 @@ export class SyncEngine {
|
|
|
889
1528
|
if (this.fallbackPollerId)
|
|
890
1529
|
return;
|
|
891
1530
|
const interval = this.config.realtimeFallbackPollMs ?? 30_000;
|
|
1531
|
+
this.updateTransportHealth({
|
|
1532
|
+
mode: 'polling',
|
|
1533
|
+
connected: false,
|
|
1534
|
+
fallbackReason: 'network',
|
|
1535
|
+
});
|
|
892
1536
|
this.fallbackPollerId = setInterval(() => {
|
|
893
1537
|
if (!this.state.isSyncing && !this.isDestroyed) {
|
|
894
1538
|
this.triggerSyncInBackground(undefined, 'realtime fallback poll');
|
|
@@ -900,6 +1544,7 @@ export class SyncEngine {
|
|
|
900
1544
|
clearInterval(this.fallbackPollerId);
|
|
901
1545
|
this.fallbackPollerId = null;
|
|
902
1546
|
}
|
|
1547
|
+
this.updateTransportHealth({ fallbackReason: null });
|
|
903
1548
|
}
|
|
904
1549
|
/**
|
|
905
1550
|
* Clear all in-memory mutation state and emit data:change so UI re-renders.
|