@syncular/client 0.0.6-219 → 0.0.6-223

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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { captureSyncException, countSyncMetric, distributionSyncMetric, isRecord, SyncTransportError, startSyncSpan, } from '@syncular/core';
8
8
  import { sql } from 'kysely';
9
+ import { SyncClientStageError } from '../errors.js';
9
10
  import { getClientHandler } from '../handlers/collection.js';
10
11
  import { ensureClientSyncSchema } from '../migrate.js';
11
12
  import { withDefaultClientPlugins } from '../plugins/index.js';
@@ -55,7 +56,10 @@ function createSyncError(args) {
55
56
  timestamp: Date.now(),
56
57
  retryable: args.retryable ?? false,
57
58
  httpStatus: args.httpStatus,
59
+ stage: args.stage,
58
60
  subscriptionId: args.subscriptionId,
61
+ chunkId: args.chunkId,
62
+ table: args.table,
59
63
  stateId: args.stateId,
60
64
  };
61
65
  }
@@ -63,17 +67,18 @@ function classifySyncFailure(error) {
63
67
  const cause = error instanceof Error ? error : new Error(String(error));
64
68
  const message = cause.message || 'Sync failed';
65
69
  const normalized = message.toLowerCase();
66
- if (cause instanceof SyncTransportError) {
67
- if (cause.status === 401 || cause.status === 403) {
70
+ const classifyTransportFailure = (transportError, stage) => {
71
+ if (transportError.status === 401 || transportError.status === 403) {
68
72
  return {
69
73
  code: 'AUTH_FAILED',
70
74
  message,
71
75
  cause,
72
76
  retryable: false,
73
- httpStatus: cause.status,
77
+ httpStatus: transportError.status,
78
+ stage,
74
79
  };
75
80
  }
76
- if (cause.status === 404 &&
81
+ if (transportError.status === 404 &&
77
82
  normalized.includes('snapshot') &&
78
83
  normalized.includes('chunk')) {
79
84
  return {
@@ -81,17 +86,21 @@ function classifySyncFailure(error) {
81
86
  message,
82
87
  cause,
83
88
  retryable: false,
84
- httpStatus: cause.status,
89
+ httpStatus: transportError.status,
90
+ stage,
85
91
  };
86
92
  }
87
- if (cause.status !== undefined &&
88
- (cause.status >= 500 || cause.status === 408 || cause.status === 429)) {
93
+ if (transportError.status !== undefined &&
94
+ (transportError.status >= 500 ||
95
+ transportError.status === 408 ||
96
+ transportError.status === 429)) {
89
97
  return {
90
98
  code: 'NETWORK_ERROR',
91
99
  message,
92
100
  cause,
93
101
  retryable: true,
94
- httpStatus: cause.status,
102
+ httpStatus: transportError.status,
103
+ stage,
95
104
  };
96
105
  }
97
106
  return {
@@ -99,8 +108,82 @@ function classifySyncFailure(error) {
99
108
  message,
100
109
  cause,
101
110
  retryable: false,
102
- httpStatus: cause.status,
111
+ httpStatus: transportError.status,
112
+ stage,
113
+ };
114
+ };
115
+ if (cause instanceof SyncClientStageError) {
116
+ const stageContext = {
117
+ stage: cause.stage,
118
+ subscriptionId: cause.subscriptionId,
119
+ chunkId: cause.chunkId,
120
+ table: cause.table,
121
+ stateId: cause.stateId,
103
122
  };
123
+ const stageCause = cause.cause instanceof Error ? cause.cause : cause;
124
+ if (stageCause instanceof SyncTransportError) {
125
+ return {
126
+ ...classifyTransportFailure(stageCause, cause.stage),
127
+ ...stageContext,
128
+ };
129
+ }
130
+ if (normalized.includes('network') ||
131
+ normalized.includes('fetch') ||
132
+ normalized.includes('timeout') ||
133
+ normalized.includes('offline')) {
134
+ return {
135
+ code: 'NETWORK_ERROR',
136
+ message,
137
+ cause,
138
+ retryable: true,
139
+ ...stageContext,
140
+ };
141
+ }
142
+ switch (cause.stage) {
143
+ case 'snapshot-gzip-decode':
144
+ return {
145
+ code: 'SNAPSHOT_GZIP_DECODE_FAILED',
146
+ message,
147
+ cause,
148
+ retryable: false,
149
+ ...stageContext,
150
+ };
151
+ case 'snapshot-chunk-decode':
152
+ return {
153
+ code: 'SNAPSHOT_CHUNK_DECODE_FAILED',
154
+ message,
155
+ cause,
156
+ retryable: false,
157
+ ...stageContext,
158
+ };
159
+ case 'snapshot-integrity':
160
+ return {
161
+ code: 'SNAPSHOT_INTEGRITY_FAILED',
162
+ message,
163
+ cause,
164
+ retryable: false,
165
+ ...stageContext,
166
+ };
167
+ case 'snapshot-apply':
168
+ return {
169
+ code: 'SNAPSHOT_APPLY_FAILED',
170
+ message,
171
+ cause,
172
+ retryable: false,
173
+ ...stageContext,
174
+ };
175
+ default:
176
+ return {
177
+ code: 'SYNC_ERROR',
178
+ message,
179
+ cause,
180
+ retryable: false,
181
+ ...stageContext,
182
+ };
183
+ }
184
+ }
185
+ if (cause instanceof SyncTransportError) {
186
+ return classifyTransportFailure(cause, 'pull');
104
187
  }
105
188
  if (normalized.includes('network') ||
106
189
  normalized.includes('fetch') ||
@@ -159,6 +242,11 @@ function serializeInspectorRecord(value) {
159
242
  function defaultSelectorEquality(left, right) {
160
243
  return Object.is(left, right);
161
244
  }
245
+ function normalizeBootstrapPhase(value) {
246
+ if (value === undefined)
247
+ return 0;
248
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
249
+ }
162
250
  function areMetadataRecordsEqual(left, right) {
163
251
  if (left === right)
164
252
  return true;
@@ -536,31 +624,77 @@ export class SyncEngine {
536
624
  completedAt: progress?.completedAt,
537
625
  lastErrorCode: progress?.lastErrorCode,
538
626
  lastErrorMessage: progress?.lastErrorMessage,
627
+ bootstrapPhase: normalizeBootstrapPhase(configuredSub?.bootstrapPhase),
539
628
  };
540
629
  });
541
630
  const expectedSubscriptions = subscriptions.filter((sub) => sub.expected);
542
- const readySubscriptionIds = expectedSubscriptions
631
+ const activePhase = expectedSubscriptions
632
+ .filter((sub) => !sub.ready)
633
+ .reduce((lowest, sub) => lowest === null || sub.bootstrapPhase < lowest
634
+ ? sub.bootstrapPhase
635
+ : lowest, null) ?? null;
636
+ const selectedMaxPhase = options.maxPhase ??
637
+ (explicitIdSet.size > 0
638
+ ? 'all'
639
+ : expectedSubscriptions.length > 0
640
+ ? expectedSubscriptions.reduce((lowest, sub) => Math.min(lowest, sub.bootstrapPhase), Number.POSITIVE_INFINITY)
641
+ : 0);
642
+ const blockingSubscriptions = expectedSubscriptions.filter((sub) => selectedMaxPhase === 'all' ? true : sub.bootstrapPhase <= selectedMaxPhase);
643
+ const readySubscriptionIds = blockingSubscriptions
543
644
  .filter((sub) => sub.ready)
544
645
  .map((sub) => sub.id);
545
- const pendingSubscriptionIds = expectedSubscriptions
646
+ const pendingSubscriptionIds = blockingSubscriptions
546
647
  .filter((sub) => !sub.ready)
547
648
  .map((sub) => sub.id);
548
649
  const channelPhase = this.resolveChannelPhase(filteredStates.map((sub) => this.mapSubscriptionToProgress(sub)));
549
- const progressPercent = expectedSubscriptions.length === 0
650
+ const progressPercent = blockingSubscriptions.length === 0
550
651
  ? pendingSubscriptionIds.length === 0
551
652
  ? 100
552
653
  : 0
553
- : Math.round(expectedSubscriptions.reduce((sum, sub) => sum + sub.progressPercent, 0) / expectedSubscriptions.length);
654
+ : Math.round(blockingSubscriptions.reduce((sum, sub) => sum + sub.progressPercent, 0) / blockingSubscriptions.length);
655
+ const phases = Array.from(expectedSubscriptions.reduce((acc, sub) => {
656
+ const current = acc.get(sub.bootstrapPhase) ?? {
657
+ phase: sub.bootstrapPhase,
658
+ expectedSubscriptionIds: [],
659
+ readySubscriptionIds: [],
660
+ pendingSubscriptionIds: [],
661
+ progressPercent: 0,
662
+ };
663
+ current.expectedSubscriptionIds.push(sub.id);
664
+ if (sub.ready) {
665
+ current.readySubscriptionIds.push(sub.id);
666
+ }
667
+ else {
668
+ current.pendingSubscriptionIds.push(sub.id);
669
+ }
670
+ current.progressPercent += sub.progressPercent;
671
+ acc.set(sub.bootstrapPhase, current);
672
+ return acc;
673
+ }, new Map()))
674
+ .sort(([left], [right]) => left - right)
675
+ .map(([, phase]) => ({
676
+ phase: phase.phase,
677
+ expectedSubscriptionIds: phase.expectedSubscriptionIds,
678
+ readySubscriptionIds: phase.readySubscriptionIds,
679
+ pendingSubscriptionIds: phase.pendingSubscriptionIds,
680
+ isReady: phase.pendingSubscriptionIds.length === 0,
681
+ progressPercent: phase.expectedSubscriptionIds.length === 0
682
+ ? 100
683
+ : Math.round(phase.progressPercent / phase.expectedSubscriptionIds.length),
684
+ }));
554
685
  return {
555
686
  stateId,
556
687
  channelPhase,
557
688
  progressPercent,
558
689
  isBootstrapping: pendingSubscriptionIds.length > 0,
559
690
  isReady: pendingSubscriptionIds.length === 0,
560
- expectedSubscriptionIds: expectedSubscriptions.map((sub) => sub.id),
691
+ expectedSubscriptionIds: blockingSubscriptions.map((sub) => sub.id),
561
692
  readySubscriptionIds,
562
693
  pendingSubscriptionIds,
563
694
  subscriptions,
695
+ activePhase,
696
+ selectedMaxPhase,
697
+ phases,
564
698
  };
565
699
  }
566
700
  /**
@@ -593,23 +727,49 @@ export class SyncEngine {
593
727
  const stateId = options.stateId ?? this.getStateId();
594
728
  const deadline = Date.now() + timeoutMs;
595
729
  while (true) {
596
- const states = await this.listSubscriptionStates({ stateId });
597
- const relevantStates = options.subscriptionId === undefined
598
- ? states
599
- : states.filter((state) => state.subscriptionId === options.subscriptionId);
600
- const hasPendingBootstrap = relevantStates.some((state) => state.status === 'active' && state.bootstrapState !== null);
601
- if (!hasPendingBootstrap) {
602
- return this.getProgress();
730
+ if (options.subscriptionId !== undefined) {
731
+ const state = await this.getSubscriptionState(options.subscriptionId, {
732
+ stateId,
733
+ });
734
+ const hasPendingBootstrap = state?.status === 'active' && state.bootstrapState !== null;
735
+ if (!hasPendingBootstrap) {
736
+ return this.getProgress();
737
+ }
738
+ }
739
+ else {
740
+ const bootstrap = await this.getBootstrapStatus({
741
+ stateId,
742
+ maxPhase: options.maxPhase,
743
+ });
744
+ if (bootstrap.isReady) {
745
+ return this.getProgress();
746
+ }
603
747
  }
604
748
  if (this.state.error) {
605
- throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`);
749
+ const detailSuffix = this.state.error.stage
750
+ ? ` [stage=${this.state.error.stage}]`
751
+ : '';
752
+ throw new Error(`[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion${detailSuffix}: ${this.state.error.message}`);
606
753
  }
607
754
  const remainingMs = deadline - Date.now();
608
755
  if (remainingMs <= 0) {
609
756
  const target = options.subscriptionId === undefined
610
757
  ? `state "${stateId}"`
611
758
  : `subscription "${options.subscriptionId}" in state "${stateId}"`;
612
- throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`);
759
+ const bootstrap = await this.getBootstrapStatus({
760
+ stateId,
761
+ subscriptionIds: options.subscriptionId === undefined
762
+ ? undefined
763
+ : [options.subscriptionId],
764
+ maxPhase: options.maxPhase,
765
+ }).catch(() => null);
766
+ const pendingSummary = bootstrap && bootstrap.pendingSubscriptionIds.length > 0
767
+ ? ` Pending subscriptions: ${bootstrap.pendingSubscriptionIds.join(', ')}.`
768
+ : '';
769
+ const phaseSummary = bootstrap && bootstrap.activePhase !== null
770
+ ? ` Active phase: ${bootstrap.activePhase}.`
771
+ : '';
772
+ throw new Error(`[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}. Bootstrap is still in progress.${phaseSummary}${pendingSummary}`);
613
773
  }
614
774
  await this.waitForProgressSignal(remainingMs);
615
775
  }
@@ -1248,6 +1408,15 @@ export class SyncEngine {
1248
1408
  }
1249
1409
  }
1250
1410
  }
1411
+ shouldTrace() {
1412
+ return (this.config.traceEnabled === true ||
1413
+ (this.listeners.get('sync:trace')?.size ?? 0) > 0);
1414
+ }
1415
+ emitTrace(payload) {
1416
+ if (!this.shouldTrace())
1417
+ return;
1418
+ this.emit('sync:trace', payload);
1419
+ }
1251
1420
  updateState(partial) {
1252
1421
  const nextState = { ...this.state, ...partial };
1253
1422
  const unchanged = this.state.enabled === nextState.enabled &&
@@ -1489,14 +1658,14 @@ export class SyncEngine {
1489
1658
  clientId: this.config.clientId,
1490
1659
  actorId: this.config.actorId ?? undefined,
1491
1660
  plugins: this.config.plugins,
1492
- subscriptions: this.config
1493
- .subscriptions,
1661
+ subscriptions: this.config.subscriptions,
1494
1662
  limitCommits: this.config.limitCommits,
1495
1663
  limitSnapshotRows: this.config.limitSnapshotRows,
1496
1664
  maxSnapshotPages: this.config.maxSnapshotPages,
1497
1665
  dedupeRows: this.config.dedupeRows,
1498
1666
  stateId: this.config.stateId,
1499
1667
  sha256: this.config.sha256,
1668
+ onTrace: (event) => this.emitTrace(event),
1500
1669
  trigger,
1501
1670
  allowSkipPullOnLocalWsPush: this.state.transportMode === 'realtime',
1502
1671
  }));
@@ -1578,7 +1747,11 @@ export class SyncEngine {
1578
1747
  cause: classified.cause,
1579
1748
  retryable: classified.retryable,
1580
1749
  httpStatus: classified.httpStatus,
1581
- stateId: this.getStateId(),
1750
+ stage: classified.stage,
1751
+ subscriptionId: classified.subscriptionId,
1752
+ chunkId: classified.chunkId,
1753
+ table: classified.table,
1754
+ stateId: classified.stateId ?? this.getStateId(),
1582
1755
  });
1583
1756
  this.updateState({
1584
1757
  isSyncing: false,