@syncular/client 0.0.2-2 → 0.0.3-12

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,9 @@ 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;
19
+ const DEFAULT_INSPECTOR_EVENT_LIMIT = 100;
20
+ const MAX_INSPECTOR_EVENT_LIMIT = 500;
17
21
  function calculateRetryDelay(attemptIndex) {
18
22
  return Math.min(INITIAL_RETRY_DELAY_MS * EXPONENTIAL_FACTOR ** attemptIndex, MAX_RETRY_DELAY_MS);
19
23
  }
@@ -22,17 +26,115 @@ function isRealtimeTransport(transport) {
22
26
  transport !== null &&
23
27
  typeof transport.connect === 'function');
24
28
  }
25
- function createSyncError(code, message, cause) {
29
+ function createSyncError(args) {
26
30
  return {
27
- code,
31
+ code: args.code,
32
+ message: args.message,
33
+ cause: args.cause,
34
+ timestamp: Date.now(),
35
+ retryable: args.retryable ?? false,
36
+ httpStatus: args.httpStatus,
37
+ subscriptionId: args.subscriptionId,
38
+ stateId: args.stateId,
39
+ };
40
+ }
41
+ function classifySyncFailure(error) {
42
+ const cause = error instanceof Error ? error : new Error(String(error));
43
+ const message = cause.message || 'Sync failed';
44
+ const normalized = message.toLowerCase();
45
+ if (cause instanceof SyncTransportError) {
46
+ if (cause.status === 401 || cause.status === 403) {
47
+ return {
48
+ code: 'AUTH_FAILED',
49
+ message,
50
+ cause,
51
+ retryable: false,
52
+ httpStatus: cause.status,
53
+ };
54
+ }
55
+ if (cause.status === 404 &&
56
+ normalized.includes('snapshot') &&
57
+ normalized.includes('chunk')) {
58
+ return {
59
+ code: 'SNAPSHOT_CHUNK_NOT_FOUND',
60
+ message,
61
+ cause,
62
+ retryable: false,
63
+ httpStatus: cause.status,
64
+ };
65
+ }
66
+ if (cause.status !== undefined &&
67
+ (cause.status >= 500 || cause.status === 408 || cause.status === 429)) {
68
+ return {
69
+ code: 'NETWORK_ERROR',
70
+ message,
71
+ cause,
72
+ retryable: true,
73
+ httpStatus: cause.status,
74
+ };
75
+ }
76
+ return {
77
+ code: 'SYNC_ERROR',
78
+ message,
79
+ cause,
80
+ retryable: false,
81
+ httpStatus: cause.status,
82
+ };
83
+ }
84
+ if (normalized.includes('network') ||
85
+ normalized.includes('fetch') ||
86
+ normalized.includes('timeout') ||
87
+ normalized.includes('offline')) {
88
+ return {
89
+ code: 'NETWORK_ERROR',
90
+ message,
91
+ cause,
92
+ retryable: true,
93
+ };
94
+ }
95
+ if (normalized.includes('conflict')) {
96
+ return {
97
+ code: 'CONFLICT',
98
+ message,
99
+ cause,
100
+ retryable: false,
101
+ };
102
+ }
103
+ return {
104
+ code: 'SYNC_ERROR',
28
105
  message,
29
106
  cause,
30
- timestamp: Date.now(),
107
+ retryable: false,
31
108
  };
32
109
  }
33
110
  function resolveSyncTriggerLabel(trigger) {
34
111
  return trigger ?? 'auto';
35
112
  }
113
+ function serializeInspectorValue(value) {
114
+ const encoded = JSON.stringify(value, (_key, nextValue) => {
115
+ if (nextValue instanceof Error) {
116
+ return {
117
+ name: nextValue.name,
118
+ message: nextValue.message,
119
+ stack: nextValue.stack,
120
+ };
121
+ }
122
+ if (typeof nextValue === 'bigint') {
123
+ return nextValue.toString();
124
+ }
125
+ return nextValue;
126
+ });
127
+ if (!encoded)
128
+ return null;
129
+ return JSON.parse(encoded);
130
+ }
131
+ function serializeInspectorRecord(value) {
132
+ const serialized = serializeInspectorValue(value);
133
+ if (isRecord(serialized)) {
134
+ return serialized;
135
+ }
136
+ return { value: serialized };
137
+ }
36
138
  export class SyncEngine {
37
139
  config;
38
140
  state;
@@ -48,6 +150,17 @@ export class SyncEngine {
48
150
  retryTimeoutId = null;
49
151
  realtimeCatchupTimeoutId = null;
50
152
  hasRealtimeConnectedOnce = false;
153
+ transportHealth = {
154
+ mode: 'disconnected',
155
+ connected: false,
156
+ lastSuccessfulPollAt: null,
157
+ lastRealtimeMessageAt: null,
158
+ fallbackReason: null,
159
+ };
160
+ activeBootstrapSubscriptions = new Set();
161
+ bootstrapStartedAt = new Map();
162
+ inspectorEvents = [];
163
+ nextInspectorEventId = 1;
51
164
  /**
52
165
  * In-memory map tracking local mutation timestamps by rowId.
53
166
  * Used for efficient fingerprint-based rerender optimization.
@@ -69,6 +182,13 @@ export class SyncEngine {
69
182
  this.config = config;
70
183
  this.listeners = new Map();
71
184
  this.state = this.createInitialState();
185
+ this.transportHealth = {
186
+ mode: this.state.transportMode === 'polling' ? 'polling' : 'disconnected',
187
+ connected: false,
188
+ lastSuccessfulPollAt: null,
189
+ lastRealtimeMessageAt: null,
190
+ fallbackReason: null,
191
+ };
72
192
  }
73
193
  /**
74
194
  * Get mutation timestamp for a row (used by query hooks for fingerprinting).
@@ -212,6 +332,140 @@ export class SyncEngine {
212
332
  getState() {
213
333
  return this.state;
214
334
  }
335
+ /**
336
+ * Get transport health details (realtime/polling/fallback).
337
+ */
338
+ getTransportHealth() {
339
+ return this.transportHealth;
340
+ }
341
+ /**
342
+ * Get subscription state metadata for the current profile.
343
+ */
344
+ async listSubscriptionStates(args) {
345
+ return readSubscriptionStates(this.config.db, {
346
+ stateId: args?.stateId ?? this.getStateId(),
347
+ table: args?.table,
348
+ status: args?.status,
349
+ });
350
+ }
351
+ /**
352
+ * Get a single subscription state by id.
353
+ */
354
+ async getSubscriptionState(subscriptionId, options) {
355
+ return readSubscriptionState(this.config.db, {
356
+ stateId: options?.stateId ?? this.getStateId(),
357
+ subscriptionId,
358
+ });
359
+ }
360
+ /**
361
+ * Get normalized progress for all active subscriptions in this state profile.
362
+ */
363
+ async getProgress() {
364
+ const subscriptions = await this.listSubscriptionStates();
365
+ const progress = subscriptions.map((sub) => this.mapSubscriptionToProgress(sub));
366
+ const channelPhase = this.resolveChannelPhase(progress);
367
+ const hasSubscriptions = progress.length > 0;
368
+ const basePercent = hasSubscriptions
369
+ ? Math.round(progress.reduce((sum, item) => sum + item.progressPercent, 0) /
370
+ progress.length)
371
+ : this.state.lastSyncAt !== null
372
+ ? 100
373
+ : 0;
374
+ const progressPercent = channelPhase === 'live'
375
+ ? 100
376
+ : Math.max(0, Math.min(100, Math.trunc(basePercent)));
377
+ return {
378
+ channelPhase,
379
+ progressPercent,
380
+ subscriptions: progress,
381
+ };
382
+ }
383
+ /**
384
+ * Wait until the channel reaches a target phase.
385
+ */
386
+ async awaitPhase(phase, options = {}) {
387
+ const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
388
+ const deadline = Date.now() + timeoutMs;
389
+ while (true) {
390
+ const progress = await this.getProgress();
391
+ if (progress.channelPhase === phase) {
392
+ return progress;
393
+ }
394
+ if (progress.channelPhase === 'error') {
395
+ const message = this.state.error?.message ?? 'Sync entered error state';
396
+ throw new Error(`[SyncEngine.awaitPhase] Failed while waiting for "${phase}": ${message}`);
397
+ }
398
+ const remainingMs = deadline - Date.now();
399
+ if (remainingMs <= 0) {
400
+ throw new Error(`[SyncEngine.awaitPhase] Timed out after ${timeoutMs}ms waiting for phase "${phase}"`);
401
+ }
402
+ await this.waitForProgressSignal(remainingMs);
403
+ }
404
+ }
405
+ /**
406
+ * Wait until bootstrap finishes for a state or a specific subscription.
407
+ */
408
+ async awaitBootstrapComplete(options = {}) {
409
+ const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS);
410
+ const stateId = options.stateId ?? this.getStateId();
411
+ const deadline = Date.now() + timeoutMs;
412
+ while (true) {
413
+ const states = await this.listSubscriptionStates({ stateId });
414
+ const relevantStates = options.subscriptionId === undefined
415
+ ? states
416
+ : states.filter((state) => state.subscriptionId === options.subscriptionId);
417
+ const hasPendingBootstrap = relevantStates.some((state) => state.status === 'active' && state.bootstrapState !== null);
418
+ if (!hasPendingBootstrap) {
419
+ return this.getProgress();
420
+ }
421
+ if (this.state.error) {
422
+ throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`);
423
+ }
424
+ const remainingMs = deadline - Date.now();
425
+ if (remainingMs <= 0) {
426
+ const target = options.subscriptionId === undefined
427
+ ? `state "${stateId}"`
428
+ : `subscription "${options.subscriptionId}" in state "${stateId}"`;
429
+ throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`);
430
+ }
431
+ await this.waitForProgressSignal(remainingMs);
432
+ }
433
+ }
434
+ /**
435
+ * Get a diagnostics snapshot suitable for debug UIs and bug reports.
436
+ */
437
+ async getDiagnostics() {
438
+ const [subscriptions, progress, outbox, conflicts] = await Promise.all([
439
+ this.listSubscriptionStates(),
440
+ this.getProgress(),
441
+ this.refreshOutboxStats({ emit: false }),
442
+ this.getConflicts(),
443
+ ]);
444
+ return {
445
+ timestamp: Date.now(),
446
+ state: this.state,
447
+ transport: this.transportHealth,
448
+ progress,
449
+ outbox,
450
+ conflictCount: conflicts.length,
451
+ subscriptions,
452
+ };
453
+ }
454
+ /**
455
+ * Get a serializable inspector snapshot for app debug UIs and support tooling.
456
+ */
457
+ async getInspectorSnapshot(options = {}) {
458
+ const diagnostics = await this.getDiagnostics();
459
+ const requestedLimit = options.eventLimit ?? DEFAULT_INSPECTOR_EVENT_LIMIT;
460
+ const eventLimit = Math.max(0, Math.min(MAX_INSPECTOR_EVENT_LIMIT, requestedLimit));
461
+ const recentEvents = eventLimit === 0 ? [] : this.inspectorEvents.slice(-eventLimit);
462
+ return {
463
+ version: 1,
464
+ generatedAt: Date.now(),
465
+ diagnostics: serializeInspectorRecord(diagnostics),
466
+ recentEvents,
467
+ };
468
+ }
215
469
  /**
216
470
  * Get database instance
217
471
  */
@@ -230,6 +484,366 @@ export class SyncEngine {
230
484
  getClientId() {
231
485
  return this.config.clientId;
232
486
  }
487
+ getStateId() {
488
+ return this.config.stateId ?? DEFAULT_SYNC_STATE_ID;
489
+ }
490
+ makeBootstrapKey(stateId, subscriptionId) {
491
+ return `${stateId}:${subscriptionId}`;
492
+ }
493
+ updateTransportHealth(partial) {
494
+ this.transportHealth = {
495
+ ...this.transportHealth,
496
+ ...partial,
497
+ };
498
+ this.emit('state:change', {});
499
+ }
500
+ waitForProgressSignal(timeoutMs) {
501
+ return new Promise((resolve) => {
502
+ const cleanups = [];
503
+ let settled = false;
504
+ const finish = () => {
505
+ if (settled)
506
+ return;
507
+ settled = true;
508
+ clearTimeout(timeoutId);
509
+ for (const cleanup of cleanups)
510
+ cleanup();
511
+ resolve();
512
+ };
513
+ const listen = (event) => {
514
+ cleanups.push(this.on(event, finish));
515
+ };
516
+ listen('sync:start');
517
+ listen('sync:complete');
518
+ listen('sync:error');
519
+ listen('sync:live');
520
+ listen('bootstrap:start');
521
+ listen('bootstrap:progress');
522
+ listen('bootstrap:complete');
523
+ const timeoutId = setTimeout(finish, Math.max(1, timeoutMs));
524
+ });
525
+ }
526
+ mapSubscriptionToProgress(subscription) {
527
+ if (subscription.status === 'revoked') {
528
+ return {
529
+ stateId: subscription.stateId,
530
+ id: subscription.subscriptionId,
531
+ table: subscription.table,
532
+ phase: 'error',
533
+ progressPercent: 0,
534
+ startedAt: subscription.createdAt,
535
+ completedAt: subscription.updatedAt,
536
+ lastErrorCode: 'SUBSCRIPTION_REVOKED',
537
+ lastErrorMessage: 'Subscription is revoked',
538
+ };
539
+ }
540
+ if (subscription.bootstrapState) {
541
+ const tableCount = Math.max(0, subscription.bootstrapState.tables.length);
542
+ const tableIndex = Math.max(0, subscription.bootstrapState.tableIndex);
543
+ const tablesProcessed = Math.min(tableCount, tableIndex);
544
+ const progressPercent = tableCount === 0
545
+ ? 0
546
+ : Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
547
+ return {
548
+ stateId: subscription.stateId,
549
+ id: subscription.subscriptionId,
550
+ table: subscription.table,
551
+ phase: 'bootstrapping',
552
+ progressPercent,
553
+ tablesProcessed,
554
+ tablesTotal: tableCount,
555
+ startedAt: this.bootstrapStartedAt.get(this.makeBootstrapKey(subscription.stateId, subscription.subscriptionId)),
556
+ };
557
+ }
558
+ if (this.state.error) {
559
+ return {
560
+ stateId: subscription.stateId,
561
+ id: subscription.subscriptionId,
562
+ table: subscription.table,
563
+ phase: 'error',
564
+ progressPercent: subscription.cursor >= 0 ? 100 : 0,
565
+ startedAt: subscription.createdAt,
566
+ lastErrorCode: this.state.error.code,
567
+ lastErrorMessage: this.state.error.message,
568
+ };
569
+ }
570
+ if (this.state.isSyncing) {
571
+ return {
572
+ stateId: subscription.stateId,
573
+ id: subscription.subscriptionId,
574
+ table: subscription.table,
575
+ phase: 'catching_up',
576
+ progressPercent: subscription.cursor >= 0 ? 90 : 0,
577
+ startedAt: subscription.createdAt,
578
+ };
579
+ }
580
+ if (subscription.cursor >= 0 || this.state.lastSyncAt !== null) {
581
+ return {
582
+ stateId: subscription.stateId,
583
+ id: subscription.subscriptionId,
584
+ table: subscription.table,
585
+ phase: 'live',
586
+ progressPercent: 100,
587
+ startedAt: subscription.createdAt,
588
+ completedAt: subscription.updatedAt,
589
+ };
590
+ }
591
+ return {
592
+ stateId: subscription.stateId,
593
+ id: subscription.subscriptionId,
594
+ table: subscription.table,
595
+ phase: 'idle',
596
+ progressPercent: 0,
597
+ startedAt: subscription.createdAt,
598
+ };
599
+ }
600
+ resolveChannelPhase(subscriptions) {
601
+ if (this.state.error)
602
+ return 'error';
603
+ if (subscriptions.some((sub) => sub.phase === 'error'))
604
+ return 'error';
605
+ if (subscriptions.some((sub) => sub.phase === 'bootstrapping')) {
606
+ return 'bootstrapping';
607
+ }
608
+ if (this.state.isSyncing) {
609
+ return this.state.lastSyncAt === null ? 'starting' : 'catching_up';
610
+ }
611
+ if (this.state.lastSyncAt !== null)
612
+ return 'live';
613
+ return 'idle';
614
+ }
615
+ deriveProgressFromPullSubscription(sub) {
616
+ const stateId = this.getStateId();
617
+ const key = this.makeBootstrapKey(stateId, sub.id);
618
+ const startedAt = this.bootstrapStartedAt.get(key);
619
+ if (sub.status === 'revoked') {
620
+ return {
621
+ stateId,
622
+ id: sub.id,
623
+ phase: 'error',
624
+ progressPercent: 0,
625
+ startedAt,
626
+ completedAt: Date.now(),
627
+ lastErrorCode: 'SUBSCRIPTION_REVOKED',
628
+ lastErrorMessage: 'Subscription is revoked',
629
+ };
630
+ }
631
+ if (sub.bootstrap && sub.bootstrapState) {
632
+ const tableCount = Math.max(0, sub.bootstrapState.tables.length);
633
+ const tableIndex = Math.max(0, sub.bootstrapState.tableIndex);
634
+ const tablesProcessed = Math.min(tableCount, tableIndex);
635
+ const progressPercent = tableCount === 0
636
+ ? 0
637
+ : Math.max(0, Math.min(100, Math.round((tablesProcessed / tableCount) * 100)));
638
+ return {
639
+ stateId,
640
+ id: sub.id,
641
+ phase: 'bootstrapping',
642
+ progressPercent,
643
+ tablesProcessed,
644
+ tablesTotal: tableCount,
645
+ startedAt,
646
+ };
647
+ }
648
+ return {
649
+ stateId,
650
+ id: sub.id,
651
+ phase: this.state.isSyncing ? 'catching_up' : 'live',
652
+ progressPercent: this.state.isSyncing ? 90 : 100,
653
+ startedAt,
654
+ completedAt: this.state.isSyncing ? undefined : Date.now(),
655
+ };
656
+ }
657
+ handleBootstrapLifecycle(response) {
658
+ const stateId = this.getStateId();
659
+ const now = Date.now();
660
+ const seenKeys = new Set();
661
+ for (const sub of response.subscriptions ?? []) {
662
+ const key = this.makeBootstrapKey(stateId, sub.id);
663
+ seenKeys.add(key);
664
+ const isBootstrapping = sub.bootstrap === true;
665
+ const wasBootstrapping = this.activeBootstrapSubscriptions.has(key);
666
+ if (isBootstrapping && !wasBootstrapping) {
667
+ this.activeBootstrapSubscriptions.add(key);
668
+ this.bootstrapStartedAt.set(key, now);
669
+ this.emit('bootstrap:start', {
670
+ timestamp: now,
671
+ stateId,
672
+ subscriptionId: sub.id,
673
+ });
674
+ }
675
+ if (isBootstrapping) {
676
+ this.emit('bootstrap:progress', {
677
+ timestamp: now,
678
+ stateId,
679
+ subscriptionId: sub.id,
680
+ progress: this.deriveProgressFromPullSubscription(sub),
681
+ });
682
+ }
683
+ if (!isBootstrapping && wasBootstrapping) {
684
+ const startedAt = this.bootstrapStartedAt.get(key) ?? now;
685
+ this.activeBootstrapSubscriptions.delete(key);
686
+ this.bootstrapStartedAt.delete(key);
687
+ this.emit('bootstrap:complete', {
688
+ timestamp: now,
689
+ stateId,
690
+ subscriptionId: sub.id,
691
+ durationMs: Math.max(0, now - startedAt),
692
+ });
693
+ }
694
+ }
695
+ for (const key of Array.from(this.activeBootstrapSubscriptions)) {
696
+ if (seenKeys.has(key))
697
+ continue;
698
+ if (!key.startsWith(`${stateId}:`))
699
+ continue;
700
+ const subscriptionId = key.slice(stateId.length + 1);
701
+ if (!subscriptionId)
702
+ continue;
703
+ const startedAt = this.bootstrapStartedAt.get(key) ?? now;
704
+ this.activeBootstrapSubscriptions.delete(key);
705
+ this.bootstrapStartedAt.delete(key);
706
+ this.emit('bootstrap:complete', {
707
+ timestamp: now,
708
+ stateId,
709
+ subscriptionId,
710
+ durationMs: Math.max(0, now - startedAt),
711
+ });
712
+ }
713
+ if (this.activeBootstrapSubscriptions.size === 0 && !this.state.error) {
714
+ this.emit('sync:live', { timestamp: now });
715
+ }
716
+ }
717
+ async resolveResetTargets(options) {
718
+ const stateId = options.stateId ?? this.getStateId();
719
+ if (options.scope === 'all') {
720
+ return readSubscriptionStates(this.config.db);
721
+ }
722
+ if (options.scope === 'state') {
723
+ return readSubscriptionStates(this.config.db, { stateId });
724
+ }
725
+ const subscriptionIds = options.subscriptionIds ?? [];
726
+ if (subscriptionIds.length === 0) {
727
+ throw new Error('[SyncEngine.reset] subscriptionIds is required when scope="subscription"');
728
+ }
729
+ const allInState = await readSubscriptionStates(this.config.db, {
730
+ stateId,
731
+ });
732
+ const wanted = new Set(subscriptionIds);
733
+ return allInState.filter((state) => wanted.has(state.subscriptionId));
734
+ }
735
+ async clearSyncedTablesForReset(trx, options, targets) {
736
+ const clearedTables = [];
737
+ if (!options.clearSyncedTables) {
738
+ return clearedTables;
739
+ }
740
+ if (options.scope === 'all') {
741
+ for (const handler of this.config.handlers.getAll()) {
742
+ await handler.clearAll({ trx, scopes: {} });
743
+ clearedTables.push(handler.table);
744
+ }
745
+ return clearedTables;
746
+ }
747
+ const seen = new Set();
748
+ for (const target of targets) {
749
+ const handler = this.config.handlers.get(target.table);
750
+ if (!handler)
751
+ continue;
752
+ const key = `${target.table}:${JSON.stringify(target.scopes)}`;
753
+ if (seen.has(key))
754
+ continue;
755
+ seen.add(key);
756
+ await handler.clearAll({ trx, scopes: target.scopes });
757
+ clearedTables.push(target.table);
758
+ }
759
+ return clearedTables;
760
+ }
761
+ async reset(options) {
762
+ const resetOptions = {
763
+ clearOutbox: false,
764
+ clearConflicts: false,
765
+ clearSyncedTables: false,
766
+ ...options,
767
+ };
768
+ const targets = await this.resolveResetTargets(resetOptions);
769
+ const stateId = resetOptions.stateId ?? this.getStateId();
770
+ this.stop();
771
+ const result = await this.config.db.transaction().execute(async (trx) => {
772
+ const clearedTables = await this.clearSyncedTablesForReset(trx, resetOptions, targets);
773
+ let deletedSubscriptionStates = 0;
774
+ if (resetOptions.scope === 'all') {
775
+ const res = await sql `
776
+ delete from ${sql.table('sync_subscription_state')}
777
+ `.execute(trx);
778
+ deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
779
+ }
780
+ else if (resetOptions.scope === 'state') {
781
+ const res = await sql `
782
+ delete from ${sql.table('sync_subscription_state')}
783
+ where ${sql.ref('state_id')} = ${sql.val(stateId)}
784
+ `.execute(trx);
785
+ deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
786
+ }
787
+ else {
788
+ const subscriptionIds = resetOptions.subscriptionIds ?? [];
789
+ const res = await sql `
790
+ delete from ${sql.table('sync_subscription_state')}
791
+ where
792
+ ${sql.ref('state_id')} = ${sql.val(stateId)}
793
+ and ${sql.ref('subscription_id')} in (${sql.join(subscriptionIds.map((id) => sql.val(id)))})
794
+ `.execute(trx);
795
+ deletedSubscriptionStates = Number(res.numAffectedRows ?? 0);
796
+ }
797
+ let deletedOutboxCommits = 0;
798
+ if (resetOptions.clearOutbox) {
799
+ const res = await sql `
800
+ delete from ${sql.table('sync_outbox_commits')}
801
+ `.execute(trx);
802
+ deletedOutboxCommits = Number(res.numAffectedRows ?? 0);
803
+ }
804
+ let deletedConflicts = 0;
805
+ if (resetOptions.clearConflicts) {
806
+ const res = await sql `
807
+ delete from ${sql.table('sync_conflicts')}
808
+ `.execute(trx);
809
+ deletedConflicts = Number(res.numAffectedRows ?? 0);
810
+ }
811
+ return {
812
+ deletedSubscriptionStates,
813
+ deletedOutboxCommits,
814
+ deletedConflicts,
815
+ clearedTables,
816
+ };
817
+ });
818
+ if (resetOptions.scope === 'all') {
819
+ this.activeBootstrapSubscriptions.clear();
820
+ this.bootstrapStartedAt.clear();
821
+ }
822
+ else {
823
+ for (const target of targets) {
824
+ const key = this.makeBootstrapKey(target.stateId, target.subscriptionId);
825
+ this.activeBootstrapSubscriptions.delete(key);
826
+ this.bootstrapStartedAt.delete(key);
827
+ }
828
+ }
829
+ this.resetLocalState();
830
+ await this.refreshOutboxStats();
831
+ this.updateState({ error: null });
832
+ return result;
833
+ }
834
+ async repair(options) {
835
+ if (options.mode !== 'rebootstrap-missing-chunks') {
836
+ throw new Error(`[SyncEngine.repair] Unsupported repair mode: ${options.mode}`);
837
+ }
838
+ return this.reset({
839
+ scope: options.subscriptionIds ? 'subscription' : 'state',
840
+ stateId: options.stateId,
841
+ subscriptionIds: options.subscriptionIds,
842
+ clearOutbox: options.clearOutbox ?? false,
843
+ clearConflicts: options.clearConflicts ?? false,
844
+ clearSyncedTables: true,
845
+ });
846
+ }
233
847
  /**
234
848
  * Subscribe to sync events
235
849
  */
@@ -253,6 +867,15 @@ export class SyncEngine {
253
867
  return this.on('state:change', callback);
254
868
  }
255
869
  emit(event, payload) {
870
+ this.inspectorEvents.push({
871
+ id: this.nextInspectorEventId++,
872
+ event,
873
+ timestamp: Date.now(),
874
+ payload: serializeInspectorRecord(payload),
875
+ });
876
+ if (this.inspectorEvents.length > MAX_INSPECTOR_EVENT_LIMIT) {
877
+ this.inspectorEvents.splice(0, this.inspectorEvents.length - MAX_INSPECTOR_EVENT_LIMIT);
878
+ }
256
879
  const eventListeners = this.listeners.get(event);
257
880
  if (eventListeners) {
258
881
  for (const listener of eventListeners) {
@@ -323,7 +946,17 @@ export class SyncEngine {
323
946
  catch (err) {
324
947
  const migrationError = err instanceof Error ? err : new Error(String(err));
325
948
  this.config.onMigrationError?.(migrationError);
326
- const error = createSyncError('SYNC_ERROR', 'Migration failed', migrationError);
949
+ const error = createSyncError({
950
+ code: 'MIGRATION_FAILED',
951
+ message: 'Migration failed',
952
+ cause: migrationError,
953
+ retryable: false,
954
+ stateId: this.getStateId(),
955
+ });
956
+ this.updateState({
957
+ isSyncing: false,
958
+ error,
959
+ });
327
960
  this.handleError(error);
328
961
  return;
329
962
  }
@@ -381,7 +1014,12 @@ export class SyncEngine {
381
1014
  pushedCommits: 0,
382
1015
  pullRounds: 0,
383
1016
  pullResponse: { ok: true, subscriptions: [] },
384
- error: createSyncError('SYNC_ERROR', 'Sync not enabled'),
1017
+ error: createSyncError({
1018
+ code: 'SYNC_ERROR',
1019
+ message: 'Sync not enabled',
1020
+ retryable: false,
1021
+ stateId: this.getStateId(),
1022
+ }),
385
1023
  };
386
1024
  }
387
1025
  this.syncPromise = this.performSyncLoop(opts?.trigger);
@@ -398,7 +1036,12 @@ export class SyncEngine {
398
1036
  pushedCommits: 0,
399
1037
  pullRounds: 0,
400
1038
  pullResponse: { ok: true, subscriptions: [] },
401
- error: createSyncError('SYNC_ERROR', 'Sync not started'),
1039
+ error: createSyncError({
1040
+ code: 'SYNC_ERROR',
1041
+ message: 'Sync not started',
1042
+ retryable: false,
1043
+ stateId: this.getStateId(),
1044
+ }),
402
1045
  };
403
1046
  do {
404
1047
  this.syncRequestedWhileRunning = false;
@@ -457,6 +1100,9 @@ export class SyncEngine {
457
1100
  retryCount: 0,
458
1101
  isRetrying: false,
459
1102
  });
1103
+ this.updateTransportHealth({
1104
+ lastSuccessfulPollAt: Date.now(),
1105
+ });
460
1106
  this.emit('sync:complete', {
461
1107
  timestamp: Date.now(),
462
1108
  pushedCommits: result.pushedCommits,
@@ -472,6 +1118,7 @@ export class SyncEngine {
472
1118
  });
473
1119
  this.config.onDataChange?.(changedTables);
474
1120
  }
1121
+ this.handleBootstrapLifecycle(result.pullResponse);
475
1122
  // Refresh outbox stats (fire-and-forget — don't block sync:complete)
476
1123
  this.refreshOutboxStats().catch((error) => {
477
1124
  console.warn('[SyncEngine] Failed to refresh outbox stats after sync:', error);
@@ -499,7 +1146,15 @@ export class SyncEngine {
499
1146
  return syncResult;
500
1147
  }
501
1148
  catch (err) {
502
- const error = createSyncError('SYNC_ERROR', err instanceof Error ? err.message : 'Sync failed', err instanceof Error ? err : undefined);
1149
+ const classified = classifySyncFailure(err);
1150
+ const error = createSyncError({
1151
+ code: classified.code,
1152
+ message: classified.message,
1153
+ cause: classified.cause,
1154
+ retryable: classified.retryable,
1155
+ httpStatus: classified.httpStatus,
1156
+ stateId: this.getStateId(),
1157
+ });
503
1158
  this.updateState({
504
1159
  isSyncing: false,
505
1160
  error,
@@ -527,7 +1182,7 @@ export class SyncEngine {
527
1182
  });
528
1183
  // Schedule retry if under max retries
529
1184
  const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
530
- if (this.state.retryCount < maxRetries) {
1185
+ if (error.retryable && this.state.retryCount < maxRetries) {
531
1186
  this.scheduleRetry();
532
1187
  }
533
1188
  return {
@@ -666,12 +1321,19 @@ export class SyncEngine {
666
1321
  retryCount: 0,
667
1322
  isRetrying: false,
668
1323
  });
1324
+ this.updateTransportHealth({
1325
+ mode: 'realtime',
1326
+ connected: true,
1327
+ fallbackReason: null,
1328
+ lastSuccessfulPollAt: Date.now(),
1329
+ });
669
1330
  this.emit('sync:complete', {
670
1331
  timestamp: Date.now(),
671
1332
  pushedCommits: 0,
672
1333
  pullRounds: 0,
673
1334
  pullResponse: { ok: true, subscriptions: [] },
674
1335
  });
1336
+ this.emit('sync:live', { timestamp: Date.now() });
675
1337
  this.refreshOutboxStats().catch((error) => {
676
1338
  console.warn('[SyncEngine] Failed to refresh outbox stats after WS apply:', error);
677
1339
  });
@@ -780,6 +1442,11 @@ export class SyncEngine {
780
1442
  }
781
1443
  }, interval);
782
1444
  this.setConnectionState('connected');
1445
+ this.updateTransportHealth({
1446
+ mode: 'polling',
1447
+ connected: true,
1448
+ fallbackReason: null,
1449
+ });
783
1450
  }
784
1451
  stopPolling() {
785
1452
  if (this.pollerId) {
@@ -795,6 +1462,11 @@ export class SyncEngine {
795
1462
  return;
796
1463
  }
797
1464
  this.setConnectionState('connecting');
1465
+ this.updateTransportHealth({
1466
+ mode: 'disconnected',
1467
+ connected: false,
1468
+ fallbackReason: null,
1469
+ });
798
1470
  const transport = this.config.transport;
799
1471
  // Wire up presence events if transport supports them
800
1472
  if (transport.onPresenceEvent) {
@@ -817,6 +1489,9 @@ export class SyncEngine {
817
1489
  }
818
1490
  this.realtimeDisconnect = transport.connect({ clientId: this.config.clientId }, (event) => {
819
1491
  if (event.event === 'sync') {
1492
+ this.updateTransportHealth({
1493
+ lastRealtimeMessageAt: Date.now(),
1494
+ });
820
1495
  countSyncMetric('sync.client.ws.events', 1, {
821
1496
  attributes: { type: 'sync' },
822
1497
  });
@@ -840,6 +1515,11 @@ export class SyncEngine {
840
1515
  const wasConnectedBefore = this.hasRealtimeConnectedOnce;
841
1516
  this.hasRealtimeConnectedOnce = true;
842
1517
  this.setConnectionState('connected');
1518
+ this.updateTransportHealth({
1519
+ mode: 'realtime',
1520
+ connected: true,
1521
+ fallbackReason: null,
1522
+ });
843
1523
  this.stopFallbackPolling();
844
1524
  this.triggerSyncInBackground(undefined, 'realtime connected state');
845
1525
  if (wasConnectedBefore) {
@@ -849,9 +1529,17 @@ export class SyncEngine {
849
1529
  }
850
1530
  case 'connecting':
851
1531
  this.setConnectionState('connecting');
1532
+ this.updateTransportHealth({
1533
+ mode: 'disconnected',
1534
+ connected: false,
1535
+ });
852
1536
  break;
853
1537
  case 'disconnected':
854
1538
  this.setConnectionState('reconnecting');
1539
+ this.updateTransportHealth({
1540
+ mode: 'disconnected',
1541
+ connected: false,
1542
+ });
855
1543
  this.startFallbackPolling();
856
1544
  break;
857
1545
  }
@@ -871,6 +1559,10 @@ export class SyncEngine {
871
1559
  this.realtimeDisconnect = null;
872
1560
  }
873
1561
  this.stopFallbackPolling();
1562
+ this.updateTransportHealth({
1563
+ mode: 'disconnected',
1564
+ connected: false,
1565
+ });
874
1566
  }
875
1567
  scheduleRealtimeReconnectCatchupSync() {
876
1568
  if (this.realtimeCatchupTimeoutId) {
@@ -889,6 +1581,11 @@ export class SyncEngine {
889
1581
  if (this.fallbackPollerId)
890
1582
  return;
891
1583
  const interval = this.config.realtimeFallbackPollMs ?? 30_000;
1584
+ this.updateTransportHealth({
1585
+ mode: 'polling',
1586
+ connected: false,
1587
+ fallbackReason: 'network',
1588
+ });
892
1589
  this.fallbackPollerId = setInterval(() => {
893
1590
  if (!this.state.isSyncing && !this.isDestroyed) {
894
1591
  this.triggerSyncInBackground(undefined, 'realtime fallback poll');
@@ -900,6 +1597,7 @@ export class SyncEngine {
900
1597
  clearInterval(this.fallbackPollerId);
901
1598
  this.fallbackPollerId = null;
902
1599
  }
1600
+ this.updateTransportHealth({ fallbackReason: null });
903
1601
  }
904
1602
  /**
905
1603
  * Clear all in-memory mutation state and emit data:change so UI re-renders.