@syncular/client 0.0.3-3 → 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.
@@ -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(code, message, cause) {
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
- timestamp: Date.now(),
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('SYNC_ERROR', 'Migration failed', migrationError);
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('SYNC_ERROR', 'Sync not enabled'),
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('SYNC_ERROR', 'Sync not started'),
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 error = createSyncError('SYNC_ERROR', err instanceof Error ? err.message : 'Sync failed', err instanceof Error ? err : undefined);
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.