@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.
@@ -14,11 +14,11 @@ import {
14
14
  type SyncOperation,
15
15
  type SyncPullResponse,
16
16
  type SyncPullSubscriptionResponse,
17
- type SyncSubscriptionRequest,
18
17
  SyncTransportError,
19
18
  startSyncSpan,
20
19
  } from '@syncular/core';
21
20
  import { type Kysely, sql, type Transaction } from 'kysely';
21
+ import { type SyncClientFailureStage, SyncClientStageError } from '../errors';
22
22
  import { getClientHandler } from '../handlers/collection';
23
23
  import { ensureClientSyncSchema } from '../migrate';
24
24
  import { withDefaultClientPlugins } from '../plugins';
@@ -53,6 +53,7 @@ import type {
53
53
  SyncBootstrapStatus,
54
54
  SyncBootstrapStatusOptions,
55
55
  SyncBootstrapSubscriptionPhase,
56
+ SyncClientSubscription,
56
57
  SyncConnectionState,
57
58
  SyncDiagnostics,
58
59
  SyncEngineConfig,
@@ -132,7 +133,10 @@ function createSyncError(args: {
132
133
  cause?: Error;
133
134
  retryable?: boolean;
134
135
  httpStatus?: number;
136
+ stage?: SyncError['stage'];
135
137
  subscriptionId?: string;
138
+ chunkId?: string;
139
+ table?: string;
136
140
  stateId?: string;
137
141
  }): SyncError {
138
142
  return {
@@ -142,7 +146,10 @@ function createSyncError(args: {
142
146
  timestamp: Date.now(),
143
147
  retryable: args.retryable ?? false,
144
148
  httpStatus: args.httpStatus,
149
+ stage: args.stage,
145
150
  subscriptionId: args.subscriptionId,
151
+ chunkId: args.chunkId,
152
+ table: args.table,
146
153
  stateId: args.stateId,
147
154
  };
148
155
  }
@@ -153,56 +160,150 @@ function classifySyncFailure(error: unknown): {
153
160
  cause: Error;
154
161
  retryable: boolean;
155
162
  httpStatus?: number;
163
+ stage?: SyncError['stage'];
164
+ subscriptionId?: string;
165
+ chunkId?: string;
166
+ table?: string;
167
+ stateId?: string;
156
168
  } {
157
169
  const cause = error instanceof Error ? error : new Error(String(error));
158
170
  const message = cause.message || 'Sync failed';
159
171
  const normalized = message.toLowerCase();
160
172
 
161
- if (cause instanceof SyncTransportError) {
162
- if (cause.status === 401 || cause.status === 403) {
173
+ const classifyTransportFailure = (
174
+ transportError: SyncTransportError,
175
+ stage?: SyncClientFailureStage
176
+ ) => {
177
+ if (transportError.status === 401 || transportError.status === 403) {
163
178
  return {
164
- code: 'AUTH_FAILED',
179
+ code: 'AUTH_FAILED' as const,
165
180
  message,
166
181
  cause,
167
182
  retryable: false,
168
- httpStatus: cause.status,
183
+ httpStatus: transportError.status,
184
+ stage,
169
185
  };
170
186
  }
171
187
 
172
188
  if (
173
- cause.status === 404 &&
189
+ transportError.status === 404 &&
174
190
  normalized.includes('snapshot') &&
175
191
  normalized.includes('chunk')
176
192
  ) {
177
193
  return {
178
- code: 'SNAPSHOT_CHUNK_NOT_FOUND',
194
+ code: 'SNAPSHOT_CHUNK_NOT_FOUND' as const,
179
195
  message,
180
196
  cause,
181
197
  retryable: false,
182
- httpStatus: cause.status,
198
+ httpStatus: transportError.status,
199
+ stage,
183
200
  };
184
201
  }
185
202
 
186
203
  if (
187
- cause.status !== undefined &&
188
- (cause.status >= 500 || cause.status === 408 || cause.status === 429)
204
+ transportError.status !== undefined &&
205
+ (transportError.status >= 500 ||
206
+ transportError.status === 408 ||
207
+ transportError.status === 429)
189
208
  ) {
190
209
  return {
191
- code: 'NETWORK_ERROR',
210
+ code: 'NETWORK_ERROR' as const,
192
211
  message,
193
212
  cause,
194
213
  retryable: true,
195
- httpStatus: cause.status,
214
+ httpStatus: transportError.status,
215
+ stage,
196
216
  };
197
217
  }
198
218
 
199
219
  return {
200
- code: 'SYNC_ERROR',
220
+ code: 'SYNC_ERROR' as const,
201
221
  message,
202
222
  cause,
203
223
  retryable: false,
204
- httpStatus: cause.status,
224
+ httpStatus: transportError.status,
225
+ stage,
205
226
  };
227
+ };
228
+
229
+ if (cause instanceof SyncClientStageError) {
230
+ const stageContext = {
231
+ stage: cause.stage,
232
+ subscriptionId: cause.subscriptionId,
233
+ chunkId: cause.chunkId,
234
+ table: cause.table,
235
+ stateId: cause.stateId,
236
+ };
237
+ const stageCause = cause.cause instanceof Error ? cause.cause : cause;
238
+
239
+ if (stageCause instanceof SyncTransportError) {
240
+ return {
241
+ ...classifyTransportFailure(stageCause, cause.stage),
242
+ ...stageContext,
243
+ };
244
+ }
245
+
246
+ if (
247
+ normalized.includes('network') ||
248
+ normalized.includes('fetch') ||
249
+ normalized.includes('timeout') ||
250
+ normalized.includes('offline')
251
+ ) {
252
+ return {
253
+ code: 'NETWORK_ERROR',
254
+ message,
255
+ cause,
256
+ retryable: true,
257
+ ...stageContext,
258
+ };
259
+ }
260
+
261
+ switch (cause.stage) {
262
+ case 'snapshot-gzip-decode':
263
+ return {
264
+ code: 'SNAPSHOT_GZIP_DECODE_FAILED',
265
+ message,
266
+ cause,
267
+ retryable: false,
268
+ ...stageContext,
269
+ };
270
+ case 'snapshot-chunk-decode':
271
+ return {
272
+ code: 'SNAPSHOT_CHUNK_DECODE_FAILED',
273
+ message,
274
+ cause,
275
+ retryable: false,
276
+ ...stageContext,
277
+ };
278
+ case 'snapshot-integrity':
279
+ return {
280
+ code: 'SNAPSHOT_INTEGRITY_FAILED',
281
+ message,
282
+ cause,
283
+ retryable: false,
284
+ ...stageContext,
285
+ };
286
+ case 'snapshot-apply':
287
+ return {
288
+ code: 'SNAPSHOT_APPLY_FAILED',
289
+ message,
290
+ cause,
291
+ retryable: false,
292
+ ...stageContext,
293
+ };
294
+ default:
295
+ return {
296
+ code: 'SYNC_ERROR',
297
+ message,
298
+ cause,
299
+ retryable: false,
300
+ ...stageContext,
301
+ };
302
+ }
303
+ }
304
+
305
+ if (cause instanceof SyncTransportError) {
306
+ return classifyTransportFailure(cause, 'pull');
206
307
  }
207
308
 
208
309
  if (
@@ -273,6 +374,11 @@ function defaultSelectorEquality<T>(left: T, right: T): boolean {
273
374
  return Object.is(left, right);
274
375
  }
275
376
 
377
+ function normalizeBootstrapPhase(value: number | undefined): number {
378
+ if (value === undefined) return 0;
379
+ return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
380
+ }
381
+
276
382
  function areMetadataRecordsEqual(
277
383
  left: Record<string, unknown> | undefined,
278
384
  right: Record<string, unknown> | undefined
@@ -736,30 +842,100 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
736
842
  completedAt: progress?.completedAt,
737
843
  lastErrorCode: progress?.lastErrorCode,
738
844
  lastErrorMessage: progress?.lastErrorMessage,
845
+ bootstrapPhase: normalizeBootstrapPhase(configuredSub?.bootstrapPhase),
739
846
  };
740
847
  });
741
848
 
742
849
  const expectedSubscriptions = subscriptions.filter((sub) => sub.expected);
743
- const readySubscriptionIds = expectedSubscriptions
850
+ const activePhase =
851
+ expectedSubscriptions
852
+ .filter((sub) => !sub.ready)
853
+ .reduce<number | null>(
854
+ (lowest, sub) =>
855
+ lowest === null || sub.bootstrapPhase < lowest
856
+ ? sub.bootstrapPhase
857
+ : lowest,
858
+ null
859
+ ) ?? null;
860
+ const selectedMaxPhase =
861
+ options.maxPhase ??
862
+ (explicitIdSet.size > 0
863
+ ? 'all'
864
+ : expectedSubscriptions.length > 0
865
+ ? expectedSubscriptions.reduce(
866
+ (lowest, sub) => Math.min(lowest, sub.bootstrapPhase),
867
+ Number.POSITIVE_INFINITY
868
+ )
869
+ : 0);
870
+ const blockingSubscriptions = expectedSubscriptions.filter((sub) =>
871
+ selectedMaxPhase === 'all' ? true : sub.bootstrapPhase <= selectedMaxPhase
872
+ );
873
+ const readySubscriptionIds = blockingSubscriptions
744
874
  .filter((sub) => sub.ready)
745
875
  .map((sub) => sub.id);
746
- const pendingSubscriptionIds = expectedSubscriptions
876
+ const pendingSubscriptionIds = blockingSubscriptions
747
877
  .filter((sub) => !sub.ready)
748
878
  .map((sub) => sub.id);
749
879
  const channelPhase = this.resolveChannelPhase(
750
880
  filteredStates.map((sub) => this.mapSubscriptionToProgress(sub))
751
881
  );
752
882
  const progressPercent =
753
- expectedSubscriptions.length === 0
883
+ blockingSubscriptions.length === 0
754
884
  ? pendingSubscriptionIds.length === 0
755
885
  ? 100
756
886
  : 0
757
887
  : Math.round(
758
- expectedSubscriptions.reduce(
888
+ blockingSubscriptions.reduce(
759
889
  (sum, sub) => sum + sub.progressPercent,
760
890
  0
761
- ) / expectedSubscriptions.length
891
+ ) / blockingSubscriptions.length
762
892
  );
893
+ const phases = Array.from(
894
+ expectedSubscriptions.reduce(
895
+ (acc, sub) => {
896
+ const current = acc.get(sub.bootstrapPhase) ?? {
897
+ phase: sub.bootstrapPhase,
898
+ expectedSubscriptionIds: [] as string[],
899
+ readySubscriptionIds: [] as string[],
900
+ pendingSubscriptionIds: [] as string[],
901
+ progressPercent: 0,
902
+ };
903
+ current.expectedSubscriptionIds.push(sub.id);
904
+ if (sub.ready) {
905
+ current.readySubscriptionIds.push(sub.id);
906
+ } else {
907
+ current.pendingSubscriptionIds.push(sub.id);
908
+ }
909
+ current.progressPercent += sub.progressPercent;
910
+ acc.set(sub.bootstrapPhase, current);
911
+ return acc;
912
+ },
913
+ new Map<
914
+ number,
915
+ {
916
+ phase: number;
917
+ expectedSubscriptionIds: string[];
918
+ readySubscriptionIds: string[];
919
+ pendingSubscriptionIds: string[];
920
+ progressPercent: number;
921
+ }
922
+ >()
923
+ )
924
+ )
925
+ .sort(([left], [right]) => left - right)
926
+ .map(([, phase]) => ({
927
+ phase: phase.phase,
928
+ expectedSubscriptionIds: phase.expectedSubscriptionIds,
929
+ readySubscriptionIds: phase.readySubscriptionIds,
930
+ pendingSubscriptionIds: phase.pendingSubscriptionIds,
931
+ isReady: phase.pendingSubscriptionIds.length === 0,
932
+ progressPercent:
933
+ phase.expectedSubscriptionIds.length === 0
934
+ ? 100
935
+ : Math.round(
936
+ phase.progressPercent / phase.expectedSubscriptionIds.length
937
+ ),
938
+ }));
763
939
 
764
940
  return {
765
941
  stateId,
@@ -767,10 +943,13 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
767
943
  progressPercent,
768
944
  isBootstrapping: pendingSubscriptionIds.length > 0,
769
945
  isReady: pendingSubscriptionIds.length === 0,
770
- expectedSubscriptionIds: expectedSubscriptions.map((sub) => sub.id),
946
+ expectedSubscriptionIds: blockingSubscriptions.map((sub) => sub.id),
771
947
  readySubscriptionIds,
772
948
  pendingSubscriptionIds,
773
949
  subscriptions,
950
+ activePhase,
951
+ selectedMaxPhase,
952
+ phases,
774
953
  };
775
954
  }
776
955
 
@@ -826,25 +1005,33 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
826
1005
  const deadline = Date.now() + timeoutMs;
827
1006
 
828
1007
  while (true) {
829
- const states = await this.listSubscriptionStates({ stateId });
830
- const relevantStates =
831
- options.subscriptionId === undefined
832
- ? states
833
- : states.filter(
834
- (state) => state.subscriptionId === options.subscriptionId
835
- );
1008
+ if (options.subscriptionId !== undefined) {
1009
+ const state = await this.getSubscriptionState(options.subscriptionId, {
1010
+ stateId,
1011
+ });
1012
+ const hasPendingBootstrap =
1013
+ state?.status === 'active' && state.bootstrapState !== null;
836
1014
 
837
- const hasPendingBootstrap = relevantStates.some(
838
- (state) => state.status === 'active' && state.bootstrapState !== null
839
- );
1015
+ if (!hasPendingBootstrap) {
1016
+ return this.getProgress();
1017
+ }
1018
+ } else {
1019
+ const bootstrap = await this.getBootstrapStatus({
1020
+ stateId,
1021
+ maxPhase: options.maxPhase,
1022
+ });
840
1023
 
841
- if (!hasPendingBootstrap) {
842
- return this.getProgress();
1024
+ if (bootstrap.isReady) {
1025
+ return this.getProgress();
1026
+ }
843
1027
  }
844
1028
 
845
1029
  if (this.state.error) {
1030
+ const detailSuffix = this.state.error.stage
1031
+ ? ` [stage=${this.state.error.stage}]`
1032
+ : '';
846
1033
  throw new Error(
847
- `[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion: ${this.state.error.message}`
1034
+ `[SyncEngine.awaitBootstrapComplete] Failed while waiting for bootstrap completion${detailSuffix}: ${this.state.error.message}`
848
1035
  );
849
1036
  }
850
1037
 
@@ -854,9 +1041,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
854
1041
  options.subscriptionId === undefined
855
1042
  ? `state "${stateId}"`
856
1043
  : `subscription "${options.subscriptionId}" in state "${stateId}"`;
1044
+ const bootstrap = await this.getBootstrapStatus({
1045
+ stateId,
1046
+ subscriptionIds:
1047
+ options.subscriptionId === undefined
1048
+ ? undefined
1049
+ : [options.subscriptionId],
1050
+ maxPhase: options.maxPhase,
1051
+ }).catch(() => null);
1052
+ const pendingSummary =
1053
+ bootstrap && bootstrap.pendingSubscriptionIds.length > 0
1054
+ ? ` Pending subscriptions: ${bootstrap.pendingSubscriptionIds.join(', ')}.`
1055
+ : '';
1056
+ const phaseSummary =
1057
+ bootstrap && bootstrap.activePhase !== null
1058
+ ? ` Active phase: ${bootstrap.activePhase}.`
1059
+ : '';
857
1060
 
858
1061
  throw new Error(
859
- `[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}`
1062
+ `[SyncEngine.awaitBootstrapComplete] Timed out after ${timeoutMs}ms waiting for ${target}. Bootstrap is still in progress.${phaseSummary}${pendingSummary}`
860
1063
  );
861
1064
  }
862
1065
 
@@ -1668,6 +1871,18 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1668
1871
  }
1669
1872
  }
1670
1873
 
1874
+ private shouldTrace(): boolean {
1875
+ return (
1876
+ this.config.traceEnabled === true ||
1877
+ (this.listeners.get('sync:trace')?.size ?? 0) > 0
1878
+ );
1879
+ }
1880
+
1881
+ private emitTrace(payload: SyncEventPayloads['sync:trace']): void {
1882
+ if (!this.shouldTrace()) return;
1883
+ this.emit('sync:trace', payload);
1884
+ }
1885
+
1671
1886
  private updateState(partial: Partial<SyncEngineState>): void {
1672
1887
  const nextState = { ...this.state, ...partial };
1673
1888
  const unchanged =
@@ -1953,14 +2168,14 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
1953
2168
  clientId: this.config.clientId!,
1954
2169
  actorId: this.config.actorId ?? undefined,
1955
2170
  plugins: this.config.plugins,
1956
- subscriptions: this.config
1957
- .subscriptions as SyncSubscriptionRequest[],
2171
+ subscriptions: this.config.subscriptions,
1958
2172
  limitCommits: this.config.limitCommits,
1959
2173
  limitSnapshotRows: this.config.limitSnapshotRows,
1960
2174
  maxSnapshotPages: this.config.maxSnapshotPages,
1961
2175
  dedupeRows: this.config.dedupeRows,
1962
2176
  stateId: this.config.stateId,
1963
2177
  sha256: this.config.sha256,
2178
+ onTrace: (event) => this.emitTrace(event),
1964
2179
  trigger,
1965
2180
  allowSkipPullOnLocalWsPush:
1966
2181
  this.state.transportMode === 'realtime',
@@ -2071,7 +2286,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2071
2286
  cause: classified.cause,
2072
2287
  retryable: classified.retryable,
2073
2288
  httpStatus: classified.httpStatus,
2074
- stateId: this.getStateId(),
2289
+ stage: classified.stage,
2290
+ subscriptionId: classified.subscriptionId,
2291
+ chunkId: classified.chunkId,
2292
+ table: classified.table,
2293
+ stateId: classified.stateId ?? this.getStateId(),
2075
2294
  });
2076
2295
 
2077
2296
  this.updateState({
@@ -2987,9 +3206,7 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2987
3206
  /**
2988
3207
  * Update subscriptions dynamically
2989
3208
  */
2990
- updateSubscriptions(
2991
- subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>
2992
- ): void {
3209
+ updateSubscriptions(subscriptions: SyncClientSubscription[]): void {
2993
3210
  this.config.subscriptions = subscriptions;
2994
3211
  // Trigger a sync to apply new subscriptions
2995
3212
  this.triggerSyncInBackground(undefined, 'subscription update');
@@ -13,6 +13,7 @@ import type {
13
13
  SyncTransport,
14
14
  } from '@syncular/core';
15
15
  import type { Kysely } from 'kysely';
16
+ import type { SyncClientFailureStage } from '../errors';
16
17
  import type { ClientHandlerCollection } from '../handlers/collection';
17
18
  import type { SyncClientPlugin } from '../plugins/types';
18
19
  import type { SyncClientDb } from '../schema';
@@ -107,6 +108,20 @@ export type SyncBootstrapSubscriptionPhase =
107
108
  | SubscriptionProgressPhase
108
109
  | 'pending';
109
110
 
111
+ export interface SyncClientSubscription
112
+ extends Omit<SyncSubscriptionRequest, 'cursor'> {
113
+ /**
114
+ * Local-only bootstrap phase for staged startup.
115
+ *
116
+ * Lower phases bootstrap first. Higher phases are deferred until all lower
117
+ * phases are ready, but once a higher-phase subscription is already ready it
118
+ * continues to participate in normal pull requests.
119
+ *
120
+ * Defaults to `0`.
121
+ */
122
+ bootstrapPhase?: number;
123
+ }
124
+
110
125
  export interface SyncBootstrapSubscriptionStatus {
111
126
  stateId: string;
112
127
  id: string;
@@ -122,6 +137,16 @@ export interface SyncBootstrapSubscriptionStatus {
122
137
  completedAt?: number;
123
138
  lastErrorCode?: string;
124
139
  lastErrorMessage?: string;
140
+ bootstrapPhase: number;
141
+ }
142
+
143
+ export interface SyncBootstrapPhaseStatus {
144
+ phase: number;
145
+ expectedSubscriptionIds: string[];
146
+ readySubscriptionIds: string[];
147
+ pendingSubscriptionIds: string[];
148
+ isReady: boolean;
149
+ progressPercent: number;
125
150
  }
126
151
 
127
152
  export interface SyncBootstrapStatus {
@@ -134,11 +159,51 @@ export interface SyncBootstrapStatus {
134
159
  readySubscriptionIds: string[];
135
160
  pendingSubscriptionIds: string[];
136
161
  subscriptions: SyncBootstrapSubscriptionStatus[];
162
+ activePhase: number | null;
163
+ selectedMaxPhase: number | 'all';
164
+ phases: SyncBootstrapPhaseStatus[];
137
165
  }
138
166
 
139
167
  export interface SyncBootstrapStatusOptions {
140
168
  stateId?: string;
141
169
  subscriptionIds?: string[];
170
+ maxPhase?: number | 'all';
171
+ }
172
+
173
+ export type SyncTraceStage =
174
+ | 'pull:start'
175
+ | 'pull:response'
176
+ | 'pull:error'
177
+ | 'apply:transaction:start'
178
+ | 'apply:transaction:complete'
179
+ | 'apply:transaction:error'
180
+ | 'apply:subscription:start'
181
+ | 'apply:subscription:complete'
182
+ | 'apply:subscription:error'
183
+ | 'apply:chunk-materialize:start'
184
+ | 'apply:chunk-materialize:complete'
185
+ | 'apply:chunk-materialize:error';
186
+
187
+ export interface SyncTraceEvent {
188
+ stage: SyncTraceStage;
189
+ timestamp: number;
190
+ stateId?: string;
191
+ subscriptionId?: string;
192
+ table?: string;
193
+ bootstrap?: boolean;
194
+ activeBootstrapPhase?: number | null;
195
+ transactionMode?: 'single-transaction' | 'per-subscription';
196
+ subscriptionIds?: string[];
197
+ subscriptionCount?: number;
198
+ commitCount?: number;
199
+ snapshotCount?: number;
200
+ chunkCount?: number;
201
+ chunkId?: string;
202
+ chunkIndex?: number;
203
+ rowCount?: number;
204
+ nextCursor?: number | null;
205
+ durationMs?: number;
206
+ errorMessage?: string;
142
207
  }
143
208
 
144
209
  /**
@@ -150,6 +215,10 @@ export interface SyncError {
150
215
  | 'NETWORK_ERROR'
151
216
  | 'AUTH_FAILED'
152
217
  | 'SNAPSHOT_CHUNK_NOT_FOUND'
218
+ | 'SNAPSHOT_GZIP_DECODE_FAILED'
219
+ | 'SNAPSHOT_CHUNK_DECODE_FAILED'
220
+ | 'SNAPSHOT_INTEGRITY_FAILED'
221
+ | 'SNAPSHOT_APPLY_FAILED'
153
222
  | 'MIGRATION_FAILED'
154
223
  | 'CONFLICT'
155
224
  | 'SYNC_ERROR'
@@ -164,8 +233,14 @@ export interface SyncError {
164
233
  retryable: boolean;
165
234
  /** HTTP status code when available */
166
235
  httpStatus?: number;
236
+ /** Sync stage where the error originated */
237
+ stage?: SyncClientFailureStage;
167
238
  /** Related subscription id when available */
168
239
  subscriptionId?: string;
240
+ /** Related snapshot chunk id when available */
241
+ chunkId?: string;
242
+ /** Related table when available */
243
+ table?: string;
169
244
  /** Related state id when available */
170
245
  stateId?: string;
171
246
  }
@@ -177,6 +252,7 @@ export type SyncEventType =
177
252
  | 'state:change'
178
253
  | 'sync:start'
179
254
  | 'sync:complete'
255
+ | 'sync:trace'
180
256
  | 'sync:live'
181
257
  | 'sync:error'
182
258
  | 'push:result'
@@ -223,6 +299,7 @@ export interface SyncEventPayloads {
223
299
  pullRounds: number;
224
300
  pullResponse: SyncPullResponse;
225
301
  };
302
+ 'sync:trace': SyncTraceEvent;
226
303
  'sync:live': { timestamp: number };
227
304
  'sync:error': SyncError;
228
305
  'push:result': PushResultInfo;
@@ -287,7 +364,7 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
287
364
  /** Stable device/app installation id */
288
365
  clientId: string | null | undefined;
289
366
  /** Subscriptions for partial sync */
290
- subscriptions: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
367
+ subscriptions: SyncClientSubscription[];
291
368
  /** Pull limit (commit count per request) */
292
369
  limitCommits?: number;
293
370
  /** Bootstrap snapshot rows per page */
@@ -344,6 +421,8 @@ export interface SyncEngineConfig<DB extends SyncClientDb = SyncClientDb> {
344
421
  plugins?: SyncClientPlugin[];
345
422
  /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
346
423
  sha256?: (bytes: Uint8Array) => Promise<string>;
424
+ /** Emit structured pull/apply tracing events to the event stream and inspector buffer. */
425
+ traceEnabled?: boolean;
347
426
  }
348
427
 
349
428
  /**
@@ -469,6 +548,7 @@ export interface SyncAwaitBootstrapOptions {
469
548
  timeoutMs?: number;
470
549
  stateId?: string;
471
550
  subscriptionId?: string;
551
+ maxPhase?: number | 'all';
472
552
  }
473
553
 
474
554
  export interface SyncDiagnostics {
package/src/errors.ts ADDED
@@ -0,0 +1,59 @@
1
+ export type SyncClientFailureStage =
2
+ | 'pull'
3
+ | 'snapshot-chunk-fetch'
4
+ | 'snapshot-gzip-decode'
5
+ | 'snapshot-chunk-decode'
6
+ | 'snapshot-integrity'
7
+ | 'snapshot-apply'
8
+ | 'bootstrap-timeout';
9
+
10
+ export interface SyncClientFailureContext {
11
+ stage: SyncClientFailureStage;
12
+ stateId?: string;
13
+ subscriptionId?: string;
14
+ table?: string;
15
+ chunkId?: string;
16
+ }
17
+
18
+ function normalizeError(error: unknown): Error {
19
+ return error instanceof Error ? error : new Error(String(error));
20
+ }
21
+
22
+ export class SyncClientStageError extends Error {
23
+ readonly stage: SyncClientFailureStage;
24
+ readonly stateId?: string;
25
+ readonly subscriptionId?: string;
26
+ readonly table?: string;
27
+ readonly chunkId?: string;
28
+
29
+ constructor(
30
+ message: string,
31
+ context: SyncClientFailureContext,
32
+ cause?: unknown
33
+ ) {
34
+ const normalizedCause =
35
+ cause === undefined ? undefined : normalizeError(cause);
36
+ super(message);
37
+ this.name = 'SyncClientStageError';
38
+ this.stage = context.stage;
39
+ this.stateId = context.stateId;
40
+ this.subscriptionId = context.subscriptionId;
41
+ this.table = context.table;
42
+ this.chunkId = context.chunkId;
43
+ if (normalizedCause) {
44
+ this.cause = normalizedCause;
45
+ }
46
+ }
47
+ }
48
+
49
+ export function wrapSyncClientStageError(
50
+ error: unknown,
51
+ context: SyncClientFailureContext,
52
+ message?: string
53
+ ): SyncClientStageError {
54
+ if (error instanceof SyncClientStageError) {
55
+ return error;
56
+ }
57
+ const cause = normalizeError(error);
58
+ return new SyncClientStageError(message ?? cause.message, context, cause);
59
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './conflicts';
8
8
  export * from './create-client';
9
9
  export * from './engine';
10
10
  export type * from './engine/types';
11
+ export * from './errors';
11
12
  export * from './handlers/collection';
12
13
  export * from './handlers/create-handler';
13
14
  export * from './handlers/types';