@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/dist/client.d.ts +14 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +22 -2
- package/dist/client.js.map +1 -1
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +115 -12
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/engine/types.d.ts +8 -1
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +202 -48
- package/dist/pull-engine.js.map +1 -1
- package/package.json +3 -3
- package/src/client.ts +44 -4
- package/src/engine/SyncEngine.test.ts +174 -0
- package/src/engine/SyncEngine.ts +140 -16
- package/src/engine/types.ts +11 -0
- package/src/errors.ts +59 -0
- package/src/index.ts +1 -0
- package/src/pull-engine.test.ts +304 -7
- package/src/pull-engine.ts +268 -56
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: {
|
|
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': {
|
|
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
|
-
? {
|
|
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', {
|
|
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
|
{
|
package/src/engine/SyncEngine.ts
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
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:
|
|
183
|
+
httpStatus: transportError.status,
|
|
184
|
+
stage,
|
|
169
185
|
};
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
if (
|
|
173
|
-
|
|
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:
|
|
198
|
+
httpStatus: transportError.status,
|
|
199
|
+
stage,
|
|
183
200
|
};
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
if (
|
|
187
|
-
|
|
188
|
-
(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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({
|
package/src/engine/types.ts
CHANGED
|
@@ -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';
|