@syncular/client 0.0.6-221 → 0.0.6-224

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.
package/src/client.ts CHANGED
@@ -147,7 +147,17 @@ export interface ClientState {
147
147
  /** Last successful sync timestamp */
148
148
  lastSyncAt: number | null;
149
149
  /** Current error if any */
150
- error: { code: string; message: string } | null;
150
+ error: {
151
+ code: string;
152
+ message: string;
153
+ stage?: string;
154
+ retryable?: boolean;
155
+ httpStatus?: number;
156
+ subscriptionId?: string;
157
+ chunkId?: string;
158
+ table?: string;
159
+ stateId?: string;
160
+ } | null;
151
161
  /** Outbox statistics */
152
162
  outbox: OutboxStats;
153
163
  }
@@ -198,7 +208,17 @@ type ClientEventPayloads = {
198
208
  'sync:complete': SyncResult;
199
209
  'sync:trace': SyncTraceEvent;
200
210
  'sync:live': { timestamp: number };
201
- 'sync:error': { code: string; message: string };
211
+ 'sync:error': {
212
+ code: string;
213
+ message: string;
214
+ stage?: string;
215
+ retryable?: boolean;
216
+ httpStatus?: number;
217
+ subscriptionId?: string;
218
+ chunkId?: string;
219
+ table?: string;
220
+ stateId?: string;
221
+ };
202
222
  'push:result': PushResultInfo;
203
223
  'bootstrap:start': {
204
224
  timestamp: number;
@@ -498,7 +518,17 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
498
518
  connectionState: engineState.connectionState,
499
519
  lastSyncAt: engineState.lastSyncAt,
500
520
  error: engineState.error
501
- ? { code: engineState.error.code, message: engineState.error.message }
521
+ ? {
522
+ code: engineState.error.code,
523
+ message: engineState.error.message,
524
+ stage: engineState.error.stage,
525
+ retryable: engineState.error.retryable,
526
+ httpStatus: engineState.error.httpStatus,
527
+ subscriptionId: engineState.error.subscriptionId,
528
+ chunkId: engineState.error.chunkId,
529
+ table: engineState.error.table,
530
+ stateId: engineState.error.stateId,
531
+ }
502
532
  : null,
503
533
  outbox: this.outboxStats,
504
534
  };
@@ -972,7 +1002,17 @@ export class Client<DB extends SyncClientDb = SyncClientDb> {
972
1002
  });
973
1003
 
974
1004
  this.engine.on('sync:error', (error) => {
975
- this.emit('sync:error', { code: error.code, message: error.message });
1005
+ this.emit('sync:error', {
1006
+ code: error.code,
1007
+ message: error.message,
1008
+ stage: error.stage,
1009
+ retryable: error.retryable,
1010
+ httpStatus: error.httpStatus,
1011
+ subscriptionId: error.subscriptionId,
1012
+ chunkId: error.chunkId,
1013
+ table: error.table,
1014
+ stateId: error.stateId,
1015
+ });
976
1016
  });
977
1017
 
978
1018
  this.engine.on('push:result', (payload) => {
@@ -745,11 +745,185 @@ describe('SyncEngine WS inline apply', () => {
745
745
 
746
746
  const state = engine.getState();
747
747
  expect(state.error?.code).toBe('SNAPSHOT_CHUNK_NOT_FOUND');
748
+ expect(state.error?.stage).toBe('pull');
748
749
  expect(state.error?.retryable).toBe(false);
749
750
  expect(state.retryCount).toBe(1);
750
751
  expect(state.isRetrying).toBe(false);
751
752
  });
752
753
 
754
+ it('classifies gzip decode failures with chunk metadata', async () => {
755
+ const invalidCompressed = new Uint8Array(
756
+ gzipSync(new TextEncoder().encode('truncated-gzip')).subarray(0, 8)
757
+ );
758
+ const transport: SyncTransport = {
759
+ capabilities: {
760
+ snapshotChunkReadMode: 'bytes',
761
+ preferMaterializedSnapshots: true,
762
+ },
763
+ async sync() {
764
+ return {
765
+ pull: {
766
+ ok: true,
767
+ subscriptions: [
768
+ {
769
+ id: 'sub-1',
770
+ status: 'active',
771
+ scopes: {},
772
+ bootstrap: true,
773
+ bootstrapState: null,
774
+ nextCursor: 1,
775
+ commits: [],
776
+ snapshots: [
777
+ {
778
+ table: 'tasks',
779
+ rows: [],
780
+ chunks: [
781
+ {
782
+ id: 'chunk-1',
783
+ byteLength: invalidCompressed.length,
784
+ sha256: '',
785
+ encoding: 'json-row-frame-v1',
786
+ compression: 'gzip',
787
+ },
788
+ ],
789
+ isFirstPage: true,
790
+ isLastPage: true,
791
+ },
792
+ ],
793
+ },
794
+ ],
795
+ },
796
+ };
797
+ },
798
+ async fetchSnapshotChunk() {
799
+ return invalidCompressed;
800
+ },
801
+ };
802
+
803
+ const handlers: ClientHandlerCollection<TestDb> = [
804
+ {
805
+ table: 'tasks',
806
+ async applySnapshot() {},
807
+ async clearAll() {},
808
+ async applyChange() {},
809
+ },
810
+ ];
811
+
812
+ const engine = new SyncEngine<TestDb>({
813
+ db,
814
+ transport,
815
+ handlers,
816
+ actorId: 'u1',
817
+ clientId: 'client-gzip-failure',
818
+ subscriptions: [
819
+ {
820
+ id: 'sub-1',
821
+ table: 'tasks',
822
+ scopes: {},
823
+ },
824
+ ],
825
+ stateId: 'default',
826
+ pollIntervalMs: 60_000,
827
+ maxRetries: 1,
828
+ });
829
+
830
+ await engine.start();
831
+ engine.stop();
832
+
833
+ const state = engine.getState();
834
+ expect(state.error?.code).toBe('SNAPSHOT_GZIP_DECODE_FAILED');
835
+ expect(state.error?.stage).toBe('snapshot-gzip-decode');
836
+ expect(state.error?.subscriptionId).toBe('sub-1');
837
+ expect(state.error?.chunkId).toBe('chunk-1');
838
+ expect(state.error?.table).toBe('tasks');
839
+ expect(state.error?.retryable).toBe(false);
840
+ });
841
+
842
+ it('classifies snapshot apply failures with stage metadata', async () => {
843
+ const rows = [{ id: 't2', title: 'new', server_version: 1 }];
844
+ const encoded = encodeSnapshotRows(rows);
845
+ const compressed = new Uint8Array(gzipSync(encoded));
846
+ const transport: SyncTransport = {
847
+ async sync() {
848
+ return {
849
+ pull: {
850
+ ok: true,
851
+ subscriptions: [
852
+ {
853
+ id: 'sub-1',
854
+ status: 'active',
855
+ scopes: {},
856
+ bootstrap: true,
857
+ bootstrapState: null,
858
+ nextCursor: 1,
859
+ commits: [],
860
+ snapshots: [
861
+ {
862
+ table: 'tasks',
863
+ rows: [],
864
+ chunks: [
865
+ {
866
+ id: 'chunk-1',
867
+ byteLength: compressed.length,
868
+ sha256: '',
869
+ encoding: 'json-row-frame-v1',
870
+ compression: 'gzip',
871
+ },
872
+ ],
873
+ isFirstPage: true,
874
+ isLastPage: true,
875
+ },
876
+ ],
877
+ },
878
+ ],
879
+ },
880
+ };
881
+ },
882
+ async fetchSnapshotChunk() {
883
+ return compressed;
884
+ },
885
+ };
886
+
887
+ const handlers: ClientHandlerCollection<TestDb> = [
888
+ {
889
+ table: 'tasks',
890
+ async applySnapshot() {
891
+ throw new Error('forced snapshot apply failure');
892
+ },
893
+ async clearAll() {},
894
+ async applyChange() {},
895
+ },
896
+ ];
897
+
898
+ const engine = new SyncEngine<TestDb>({
899
+ db,
900
+ transport,
901
+ handlers,
902
+ actorId: 'u1',
903
+ clientId: 'client-apply-failure',
904
+ subscriptions: [
905
+ {
906
+ id: 'sub-1',
907
+ table: 'tasks',
908
+ scopes: {},
909
+ },
910
+ ],
911
+ stateId: 'default',
912
+ pollIntervalMs: 60_000,
913
+ maxRetries: 1,
914
+ });
915
+
916
+ await engine.start();
917
+ engine.stop();
918
+
919
+ const state = engine.getState();
920
+ expect(state.error?.code).toBe('SNAPSHOT_APPLY_FAILED');
921
+ expect(state.error?.stage).toBe('snapshot-apply');
922
+ expect(state.error?.subscriptionId).toBe('sub-1');
923
+ expect(state.error?.table).toBe('tasks');
924
+ expect(state.error?.retryable).toBe(false);
925
+ });
926
+
753
927
  it('skips outbox and conflict refresh after a read-only successful sync', async () => {
754
928
  const handlers: ClientHandlerCollection<TestDb> = [
755
929
  {
@@ -18,6 +18,7 @@ import {
18
18
  startSyncSpan,
19
19
  } from '@syncular/core';
20
20
  import { type Kysely, sql, type Transaction } from 'kysely';
21
+ import { type SyncClientFailureStage, SyncClientStageError } from '../errors';
21
22
  import { getClientHandler } from '../handlers/collection';
22
23
  import { ensureClientSyncSchema } from '../migrate';
23
24
  import { withDefaultClientPlugins } from '../plugins';
@@ -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,
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,
205
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 (
@@ -926,8 +1027,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
926
1027
  }
927
1028
 
928
1029
  if (this.state.error) {
1030
+ const detailSuffix = this.state.error.stage
1031
+ ? ` [stage=${this.state.error.stage}]`
1032
+ : '';
929
1033
  throw new Error(
930
- `[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}`
931
1035
  );
932
1036
  }
933
1037
 
@@ -937,9 +1041,25 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
937
1041
  options.subscriptionId === undefined
938
1042
  ? `state "${stateId}"`
939
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
+ : '';
940
1060
 
941
1061
  throw new Error(
942
- `[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}`
943
1063
  );
944
1064
  }
945
1065
 
@@ -2166,7 +2286,11 @@ export class SyncEngine<DB extends SyncClientDb = SyncClientDb> {
2166
2286
  cause: classified.cause,
2167
2287
  retryable: classified.retryable,
2168
2288
  httpStatus: classified.httpStatus,
2169
- 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(),
2170
2294
  });
2171
2295
 
2172
2296
  this.updateState({
@@ -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';
@@ -214,6 +215,10 @@ export interface SyncError {
214
215
  | 'NETWORK_ERROR'
215
216
  | 'AUTH_FAILED'
216
217
  | 'SNAPSHOT_CHUNK_NOT_FOUND'
218
+ | 'SNAPSHOT_GZIP_DECODE_FAILED'
219
+ | 'SNAPSHOT_CHUNK_DECODE_FAILED'
220
+ | 'SNAPSHOT_INTEGRITY_FAILED'
221
+ | 'SNAPSHOT_APPLY_FAILED'
217
222
  | 'MIGRATION_FAILED'
218
223
  | 'CONFLICT'
219
224
  | 'SYNC_ERROR'
@@ -228,8 +233,14 @@ export interface SyncError {
228
233
  retryable: boolean;
229
234
  /** HTTP status code when available */
230
235
  httpStatus?: number;
236
+ /** Sync stage where the error originated */
237
+ stage?: SyncClientFailureStage;
231
238
  /** Related subscription id when available */
232
239
  subscriptionId?: string;
240
+ /** Related snapshot chunk id when available */
241
+ chunkId?: string;
242
+ /** Related table when available */
243
+ table?: string;
233
244
  /** Related state id when available */
234
245
  stateId?: string;
235
246
  }
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';