@syncular/client 0.0.3-3 → 0.0.3-7

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