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