Skip to content

Commit 4230188

Browse files
[Fix] CRUD Upload on Reconnect (#203)
1 parent 45eed64 commit 4230188

File tree

21 files changed

+300
-30
lines changed

21 files changed

+300
-30
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,37 @@
33
All notable changes to this project will be documented in this file.
44
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
55

6+
## 2024-10-31
7+
8+
### Changes
9+
10+
---
11+
12+
Packages with breaking changes:
13+
14+
- There are no breaking changes in this release.
15+
16+
Packages with other changes:
17+
18+
- [`powersync` - `v1.8.9`](#powersync---v189)
19+
- [`powersync_attachments_helper` - `v0.6.13`](#powersync_attachments_helper---v0613)
20+
- [`powersync_flutter_libs` - `v0.4.2`](#powersync_flutter_libs---v042)
21+
22+
---
23+
24+
#### `powersync` - `v1.8.9`
25+
26+
- **FIX**: Issue where CRUD uploads were not triggered when the SDK reconnected to the PowerSync service after being offline.
27+
28+
#### `powersync_attachments_helper` - `v0.6.13`
29+
30+
- Update a dependency to the latest release.
31+
32+
#### `powersync_flutter_libs` - `v0.4.2`
33+
34+
- Update a dependency to the latest release.
35+
36+
637
## 2024-10-21
738

839
### Changes

demos/django-todolist/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ environment:
1010
dependencies:
1111
flutter:
1212
sdk: flutter
13-
powersync: ^1.8.8
13+
powersync: ^1.8.9
1414
path_provider: ^2.1.1
1515
path: ^1.8.3
1616
logging: ^1.2.0

demos/supabase-anonymous-auth/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ dependencies:
1111
flutter:
1212
sdk: flutter
1313

14-
powersync: ^1.8.8
14+
powersync: ^1.8.9
1515
path_provider: ^2.1.1
1616
supabase_flutter: ^2.0.2
1717
path: ^1.8.3

demos/supabase-edge-function-auth/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ dependencies:
1111
flutter:
1212
sdk: flutter
1313

14-
powersync: ^1.8.8
14+
powersync: ^1.8.9
1515
path_provider: ^2.1.1
1616
supabase_flutter: ^2.0.2
1717
path: ^1.8.3

demos/supabase-simple-chat/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies:
3737

3838
supabase_flutter: ^2.0.2
3939
timeago: ^3.6.0
40-
powersync: ^1.8.8
40+
powersync: ^1.8.9
4141
path_provider: ^2.1.1
4242
path: ^1.8.3
4343
logging: ^1.2.0

demos/supabase-todolist-drift/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ environment:
99
dependencies:
1010
flutter:
1111
sdk: flutter
12-
powersync_attachments_helper: ^0.6.12
13-
powersync: ^1.8.8
12+
powersync_attachments_helper: ^0.6.13
13+
powersync: ^1.8.9
1414
path_provider: ^2.1.1
1515
supabase_flutter: ^2.0.1
1616
path: ^1.8.3

demos/supabase-todolist-optional-sync/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ environment:
1010
dependencies:
1111
flutter:
1212
sdk: flutter
13-
powersync: ^1.8.8
13+
powersync: ^1.8.9
1414
path_provider: ^2.1.1
1515
supabase_flutter: ^2.0.1
1616
path: ^1.8.3

demos/supabase-todolist/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ environment:
1010
dependencies:
1111
flutter:
1212
sdk: flutter
13-
powersync_attachments_helper: ^0.6.12
14-
powersync: ^1.8.8
13+
powersync_attachments_helper: ^0.6.13
14+
powersync: ^1.8.9
1515
path_provider: ^2.1.1
1616
supabase_flutter: ^2.0.1
1717
path: ^1.8.3

packages/powersync/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.8.9
2+
3+
- **FIX**: issue where CRUD uploads were not triggered when the SDK reconnected to the PowerSync service after being offline.
4+
15
## 1.8.8
26

37
- Update dependency `powersync_flutter_libs`

packages/powersync/lib/src/streaming_sync.dart

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class StreamingSyncImplementation {
2727

2828
final Future<void> Function() uploadCrud;
2929

30+
// An internal controller which is used to trigger CRUD uploads internally
31+
// e.g. when reconnecting.
32+
// This is only a broadcast controller since the `crudLoop` method is public
33+
// and could potentially be called multiple times externally.
34+
final StreamController<Null> _internalCrudTriggerController =
35+
StreamController<Null>.broadcast();
36+
3037
final Stream crudUpdateTriggerStream;
3138

3239
final StreamController<SyncStatus> _statusStreamController =
@@ -92,6 +99,9 @@ class StreamingSyncImplementation {
9299
if (_safeToClose) {
93100
_client.close();
94101
}
102+
103+
await _internalCrudTriggerController.close();
104+
95105
// wait for completeAbort() to be called
96106
await future;
97107

@@ -144,7 +154,7 @@ class StreamingSyncImplementation {
144154

145155
// On error, wait a little before retrying
146156
// When aborting, don't wait
147-
await Future.any([Future.delayed(retryDelay), _abort!.onAbort]);
157+
await _delayRetry();
148158
}
149159
}
150160
} finally {
@@ -155,10 +165,14 @@ class StreamingSyncImplementation {
155165
Future<void> crudLoop() async {
156166
await uploadAllCrud();
157167

158-
await for (var _ in crudUpdateTriggerStream) {
159-
if (_abort?.aborted == true) {
160-
break;
161-
}
168+
// Trigger a CRUD upload whenever the upstream trigger fires
169+
// as-well-as whenever the sync stream reconnects.
170+
// This has the potential (in rare cases) to affect the crudThrottleTime,
171+
// but it should not result in excessive uploads since the
172+
// sync reconnects are also throttled.
173+
// The stream here is closed on abort.
174+
await for (var _ in mergeStreams(
175+
[crudUpdateTriggerStream, _internalCrudTriggerController.stream])) {
162176
await uploadAllCrud();
163177
}
164178
}
@@ -170,6 +184,13 @@ class StreamingSyncImplementation {
170184

171185
while (true) {
172186
try {
187+
// It's possible that an abort or disconnect operation could
188+
// be followed by a `close` operation. The close would cause these
189+
// operations, which use the DB, to throw an exception. Breaking the loop
190+
// here prevents unnecessary potential (caught) exceptions.
191+
if (aborted) {
192+
break;
193+
}
173194
// This is the first item in the FIFO CRUD queue.
174195
CrudEntry? nextCrudItem = await adapter.nextCrudItem();
175196
if (nextCrudItem != null) {
@@ -196,7 +217,7 @@ class StreamingSyncImplementation {
196217
checkedCrudItem = null;
197218
isolateLogger.warning('Data upload error', e, stacktrace);
198219
_updateStatus(uploading: false, uploadError: e);
199-
await Future.delayed(retryDelay);
220+
await _delayRetry();
200221
if (!isConnected) {
201222
// Exit the upload loop if the sync stream is no longer connected
202223
break;
@@ -298,6 +319,9 @@ class StreamingSyncImplementation {
298319
Future<void>? credentialsInvalidation;
299320
bool haveInvalidated = false;
300321

322+
// Trigger a CRUD upload on reconnect
323+
_internalCrudTriggerController.add(null);
324+
301325
await for (var line in merged) {
302326
if (aborted) {
303327
break;
@@ -465,6 +489,12 @@ class StreamingSyncImplementation {
465489
yield parseStreamingSyncLine(line as Map<String, dynamic>);
466490
}
467491
}
492+
493+
/// Delays the standard `retryDelay` Duration, but exits early if
494+
/// an abort has been requested.
495+
Future<void> _delayRetry() async {
496+
await Future.any([Future.delayed(retryDelay), _abort!.onAbort]);
497+
}
468498
}
469499

470500
/// Attempt to give a basic summary of the error for cases where the full error
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
const String libraryVersion = '1.8.8';
1+
const String libraryVersion = '1.8.9';

packages/powersync/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: powersync
2-
version: 1.8.8
2+
version: 1.8.9
33
homepage: https://powersync.com
44
repository: https://github.com/powersync-ja/powersync.dart
55
description: PowerSync Flutter SDK - sync engine for building local-first apps.
@@ -16,7 +16,7 @@ dependencies:
1616
sqlite3: ^2.4.6
1717
universal_io: ^2.0.0
1818
sqlite3_flutter_libs: ^0.5.23
19-
powersync_flutter_libs: ^0.4.1
19+
powersync_flutter_libs: ^0.4.2
2020
meta: ^1.0.0
2121
http: ^1.1.0
2222
uuid: ^4.2.0
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
@TestOn('!browser')
2+
// This test uses a local server which is possible to control in Web via hybrid main,
3+
// but this makes the test significantly more complex.
4+
import 'dart:async';
5+
6+
import 'package:powersync/powersync.dart';
7+
import 'package:test/test.dart';
8+
9+
import 'server/sync_server/mock_sync_server.dart';
10+
import 'streaming_sync_test.dart';
11+
import 'utils/abstract_test_utils.dart';
12+
import 'utils/test_utils_impl.dart';
13+
14+
final testUtils = TestUtils();
15+
16+
void main() {
17+
group('connected tests', () {
18+
late String path;
19+
setUp(() async {
20+
path = testUtils.dbPath();
21+
});
22+
23+
tearDown(() async {
24+
await testUtils.cleanDb(path: path);
25+
});
26+
27+
createTestServer() async {
28+
final testServer = TestHttpServerHelper();
29+
await testServer.start();
30+
addTearDown(() => testServer.stop());
31+
return testServer;
32+
}
33+
34+
test('should connect to mock PowerSync instance', () async {
35+
final testServer = await createTestServer();
36+
final connector = TestConnector(() async {
37+
return PowerSyncCredentials(
38+
endpoint: testServer.uri.toString(),
39+
token: 'token not used here',
40+
expiresAt: DateTime.now());
41+
});
42+
43+
final db = PowerSyncDatabase.withFactory(
44+
await testUtils.testFactory(path: path),
45+
schema: defaultSchema,
46+
maxReaders: 3);
47+
await db.initialize();
48+
49+
final connectedCompleter = Completer();
50+
51+
db.statusStream.listen((status) {
52+
if (status.connected) {
53+
connectedCompleter.complete();
54+
}
55+
});
56+
57+
// Add a basic command for the test server to send
58+
testServer.addEvent('{"token_expires_in": 3600}\n');
59+
60+
await db.connect(connector: connector);
61+
await connectedCompleter.future;
62+
63+
expect(db.connected, isTrue);
64+
await db.disconnect();
65+
});
66+
67+
test('should trigger uploads when connection is re-established', () async {
68+
int uploadCounter = 0;
69+
Completer uploadTriggeredCompleter = Completer();
70+
final testServer = await createTestServer();
71+
final connector = TestConnector(() async {
72+
return PowerSyncCredentials(
73+
endpoint: testServer.uri.toString(),
74+
token: 'token not used here',
75+
expiresAt: DateTime.now());
76+
}, uploadData: (database) async {
77+
uploadCounter++;
78+
uploadTriggeredCompleter.complete();
79+
throw Exception('No uploads occur here');
80+
});
81+
82+
final db = PowerSyncDatabase.withFactory(
83+
await testUtils.testFactory(path: path),
84+
schema: defaultSchema,
85+
maxReaders: 3);
86+
await db.initialize();
87+
88+
// Create an item which should trigger an upload.
89+
await db.execute(
90+
'INSERT INTO customers (id, name) VALUES (uuid(), ?)', ['steven']);
91+
92+
// Create a new completer to await the next upload
93+
uploadTriggeredCompleter = Completer();
94+
95+
// Connect the PowerSync instance
96+
final connectedCompleter = Completer();
97+
// The first connection attempt will fail
98+
final connectedErroredCompleter = Completer();
99+
100+
db.statusStream.listen((status) {
101+
if (status.connected && !connectedCompleter.isCompleted) {
102+
connectedCompleter.complete();
103+
}
104+
if (status.downloadError != null &&
105+
!connectedErroredCompleter.isCompleted) {
106+
connectedErroredCompleter.complete();
107+
}
108+
});
109+
110+
// The first command will not be valid, this simulates a failed connection
111+
testServer.addEvent('asdf\n');
112+
await db.connect(connector: connector);
113+
114+
// The connect operation should have triggered an upload (even though it fails to connect)
115+
await uploadTriggeredCompleter.future;
116+
expect(uploadCounter, equals(1));
117+
// Create a new completer for the next iteration
118+
uploadTriggeredCompleter = Completer();
119+
120+
// Connection attempt should initially fail
121+
await connectedErroredCompleter.future;
122+
expect(db.currentStatus.anyError, isNotNull);
123+
124+
// Now send a valid command. Which will result in successful connection
125+
await testServer.clearEvents();
126+
testServer.addEvent('{"token_expires_in": 3600}\n');
127+
await connectedCompleter.future;
128+
expect(db.connected, isTrue);
129+
130+
await uploadTriggeredCompleter.future;
131+
expect(uploadCounter, equals(2));
132+
133+
await db.disconnect();
134+
});
135+
});
136+
}

0 commit comments

Comments
 (0)