Skip to content

Commit b722378

Browse files
authored
feat: Exposing upload and download errors in SyncStatus (#550)
1 parent 1c2ee86 commit b722378

File tree

5 files changed

+143
-6
lines changed

5 files changed

+143
-6
lines changed

.changeset/unlucky-flies-clap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/common': minor
3+
---
4+
5+
Added `downloadError` and `uploadError` members to `SyncDataFlowStatus` of `SyncStatus`.

packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ The next upload iteration will be delayed.`);
290290

291291
checkedCrudItem = nextCrudItem;
292292
await this.options.uploadCrud();
293+
this.updateSyncStatus({
294+
dataFlow: {
295+
uploadError: undefined
296+
}
297+
});
293298
} else {
294299
// Uploading is completed
295300
await this.options.adapter.updateLocalTarget(() => this.getWriteCheckpoint());
@@ -299,7 +304,8 @@ The next upload iteration will be delayed.`);
299304
checkedCrudItem = undefined;
300305
this.updateSyncStatus({
301306
dataFlow: {
302-
uploading: false
307+
uploading: false,
308+
uploadError: ex
303309
}
304310
});
305311
await this.delayRetry();
@@ -453,6 +459,12 @@ The next upload iteration will be delayed.`);
453459
this.logger.error(ex);
454460
}
455461

462+
this.updateSyncStatus({
463+
dataFlow: {
464+
downloadError: ex
465+
}
466+
});
467+
456468
// On error, wait a little before retrying
457469
await this.delayRetry();
458470
} finally {
@@ -588,7 +600,8 @@ The next upload iteration will be delayed.`);
588600
connected: true,
589601
lastSyncedAt: new Date(),
590602
dataFlow: {
591-
downloading: false
603+
downloading: false,
604+
downloadError: undefined
592605
}
593606
});
594607
}
@@ -688,7 +701,10 @@ The next upload iteration will be delayed.`);
688701
this.updateSyncStatus({
689702
connected: true,
690703
lastSyncedAt: new Date(),
691-
priorityStatusEntries: []
704+
priorityStatusEntries: [],
705+
dataFlow: {
706+
downloadError: undefined
707+
}
692708
});
693709
} else if (validatedCheckpoint === targetCheckpoint) {
694710
const result = await this.options.adapter.syncLocalDatabase(targetCheckpoint!);
@@ -707,7 +723,8 @@ The next upload iteration will be delayed.`);
707723
lastSyncedAt: new Date(),
708724
priorityStatusEntries: [],
709725
dataFlow: {
710-
downloading: false
726+
downloading: false,
727+
downloadError: undefined
711728
}
712729
});
713730
}

packages/common/src/db/crud/SyncStatus.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
export type SyncDataFlowStatus = Partial<{
22
downloading: boolean;
33
uploading: boolean;
4+
/**
5+
* Error during downloading (including connecting).
6+
*
7+
* Cleared on the next successful data download.
8+
*/
9+
downloadError?: Error;
10+
/**
11+
* Error during uploading.
12+
* Cleared on the next successful upload.
13+
*/
14+
uploadError?: Error;
415
}>;
516

617
export interface SyncPriorityStatus {
@@ -112,7 +123,7 @@ export class SyncStatus {
112123

113124
getMessage() {
114125
const dataFlow = this.dataFlowStatus;
115-
return `SyncStatus<connected: ${this.connected} connecting: ${this.connecting} lastSyncedAt: ${this.lastSyncedAt} hasSynced: ${this.hasSynced}. Downloading: ${dataFlow.downloading}. Uploading: ${dataFlow.uploading}`;
126+
return `SyncStatus<connected: ${this.connected} connecting: ${this.connecting} lastSyncedAt: ${this.lastSyncedAt} hasSynced: ${this.hasSynced}. Downloading: ${dataFlow.downloading}. Uploading: ${dataFlow.uploading}. UploadError: ${dataFlow.uploadError}, DownloadError?: ${dataFlow.downloadError}>`;
116127
}
117128

118129
toJSON(): SyncStatusOptions {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { ConnectedDatabaseUtils, generateConnectedDatabase } from './utils/generateConnectedDatabase';
3+
4+
const UPLOAD_TIMEOUT_MS = 3000;
5+
6+
describe(
7+
'Sync Status when streaming',
8+
{ sequential: true },
9+
describeSyncStatusStreamingTests(() =>
10+
generateConnectedDatabase({
11+
powerSyncOptions: {
12+
flags: {
13+
useWebWorker: false,
14+
enableMultiTabs: false
15+
}
16+
}
17+
})
18+
)
19+
);
20+
21+
function describeSyncStatusStreamingTests(createConnectedDatabase: () => Promise<ConnectedDatabaseUtils>) {
22+
return () => {
23+
it('Should have downloadError on stream failure', async () => {
24+
const { powersync, waitForStream, remote, connector } = await createConnectedDatabase();
25+
remote.errorOnStreamStart = true;
26+
27+
// Making sure the field change takes effect
28+
const newStream = waitForStream();
29+
remote.streamController?.close();
30+
await newStream;
31+
32+
let resolveDownloadError: () => void;
33+
const downloadErrorPromise = new Promise<void>((resolve) => {
34+
resolveDownloadError = resolve;
35+
});
36+
let receivedUploadError = false;
37+
38+
powersync.registerListener({
39+
statusChanged: (status) => {
40+
if (status.dataFlowStatus.downloadError) {
41+
resolveDownloadError();
42+
receivedUploadError = true;
43+
}
44+
}
45+
});
46+
47+
// Download error should be specified
48+
await downloadErrorPromise;
49+
});
50+
51+
it('Should have uploadError on failed uploads', async () => {
52+
const { powersync, uploadSpy } = await createConnectedDatabase();
53+
expect(powersync.connected).toBe(true);
54+
55+
let uploadCounter = 0;
56+
// This test will throw an exception a few times before successfully uploading
57+
const throwCounter = 2;
58+
uploadSpy.mockImplementation(async (db) => {
59+
if (uploadCounter++ < throwCounter) {
60+
throw new Error(`Force upload error`);
61+
}
62+
// Now actually do the upload
63+
const tx = await db.getNextCrudTransaction();
64+
await tx?.complete();
65+
});
66+
67+
let resolveUploadError: () => void;
68+
const uploadErrorPromise = new Promise<void>((resolve) => {
69+
resolveUploadError = resolve;
70+
});
71+
let receivedUploadError = false;
72+
73+
let resolveClearedUploadError: () => void;
74+
const clearedUploadErrorPromise = new Promise<void>((resolve) => {
75+
resolveClearedUploadError = resolve;
76+
});
77+
78+
powersync.registerListener({
79+
statusChanged: (status) => {
80+
if (status.dataFlowStatus.uploadError) {
81+
resolveUploadError();
82+
receivedUploadError = true;
83+
} else if (receivedUploadError) {
84+
resolveClearedUploadError();
85+
}
86+
}
87+
});
88+
89+
// do something which should trigger an upload
90+
await powersync.execute('INSERT INTO users (id, name) VALUES (uuid(), ?)', ['name']);
91+
92+
// Upload error should be specified
93+
await uploadErrorPromise;
94+
95+
// Upload error should be cleared after successful upload
96+
await clearedUploadErrorPromise;
97+
});
98+
};
99+
}

packages/web/tests/utils/MockStreamOpenFactory.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class TestConnector implements PowerSyncBackendConnector {
3434

3535
export class MockRemote extends AbstractRemote {
3636
streamController: ReadableStreamDefaultController<StreamingSyncLine> | null;
37-
37+
errorOnStreamStart = false;
3838
constructor(
3939
connector: RemoteConnector,
4040
protected onStreamRequested: () => void
@@ -61,6 +61,7 @@ export class MockRemote extends AbstractRemote {
6161
}
6262
throw new Error('Not implemented');
6363
}
64+
6465
async postStreaming(
6566
path: string,
6667
data: any,
@@ -71,6 +72,10 @@ export class MockRemote extends AbstractRemote {
7172
start: (controller) => {
7273
this.streamController = controller;
7374
this.onStreamRequested();
75+
if (this.errorOnStreamStart) {
76+
controller.error(new Error('Mock error on stream start'));
77+
}
78+
7479
signal?.addEventListener('abort', () => {
7580
try {
7681
controller.close();

0 commit comments

Comments
 (0)