diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f4ee0e..ce8714ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2024-10-31 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync` - `v1.8.9`](#powersync---v189) + - [`powersync_attachments_helper` - `v0.6.13`](#powersync_attachments_helper---v0613) + - [`powersync_flutter_libs` - `v0.4.2`](#powersync_flutter_libs---v042) + +--- + +#### `powersync` - `v1.8.9` + + - **FIX**: Issue where CRUD uploads were not triggered when the SDK reconnected to the PowerSync service after being offline. + +#### `powersync_attachments_helper` - `v0.6.13` + + - Update a dependency to the latest release. + +#### `powersync_flutter_libs` - `v0.4.2` + + - Update a dependency to the latest release. + + ## 2024-10-21 ### Changes diff --git a/demos/django-todolist/pubspec.yaml b/demos/django-todolist/pubspec.yaml index 4a9bd9af..317acd12 100644 --- a/demos/django-todolist/pubspec.yaml +++ b/demos/django-todolist/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: flutter: sdk: flutter - powersync: ^1.8.8 + powersync: ^1.8.9 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 diff --git a/demos/supabase-anonymous-auth/pubspec.yaml b/demos/supabase-anonymous-auth/pubspec.yaml index 982f10ee..6550e3e1 100644 --- a/demos/supabase-anonymous-auth/pubspec.yaml +++ b/demos/supabase-anonymous-auth/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter - powersync: ^1.8.8 + powersync: ^1.8.9 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 diff --git a/demos/supabase-edge-function-auth/pubspec.yaml b/demos/supabase-edge-function-auth/pubspec.yaml index 6bd0a91b..4a9d4985 100644 --- a/demos/supabase-edge-function-auth/pubspec.yaml +++ b/demos/supabase-edge-function-auth/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter - powersync: ^1.8.8 + powersync: ^1.8.9 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 diff --git a/demos/supabase-simple-chat/pubspec.yaml b/demos/supabase-simple-chat/pubspec.yaml index 9968ad8b..f0a1c9c6 100644 --- a/demos/supabase-simple-chat/pubspec.yaml +++ b/demos/supabase-simple-chat/pubspec.yaml @@ -37,7 +37,7 @@ dependencies: supabase_flutter: ^2.0.2 timeago: ^3.6.0 - powersync: ^1.8.8 + powersync: ^1.8.9 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 diff --git a/demos/supabase-todolist-drift/pubspec.yaml b/demos/supabase-todolist-drift/pubspec.yaml index 630b43a9..bf77f1a9 100644 --- a/demos/supabase-todolist-drift/pubspec.yaml +++ b/demos/supabase-todolist-drift/pubspec.yaml @@ -9,8 +9,8 @@ environment: dependencies: flutter: sdk: flutter - powersync_attachments_helper: ^0.6.12 - powersync: ^1.8.8 + powersync_attachments_helper: ^0.6.13 + powersync: ^1.8.9 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 diff --git a/demos/supabase-todolist-optional-sync/pubspec.yaml b/demos/supabase-todolist-optional-sync/pubspec.yaml index 280d17fb..94f83b9d 100644 --- a/demos/supabase-todolist-optional-sync/pubspec.yaml +++ b/demos/supabase-todolist-optional-sync/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: flutter: sdk: flutter - powersync: ^1.8.8 + powersync: ^1.8.9 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 diff --git a/demos/supabase-todolist/pubspec.yaml b/demos/supabase-todolist/pubspec.yaml index d4d64314..3e34efb7 100644 --- a/demos/supabase-todolist/pubspec.yaml +++ b/demos/supabase-todolist/pubspec.yaml @@ -10,8 +10,8 @@ environment: dependencies: flutter: sdk: flutter - powersync_attachments_helper: ^0.6.12 - powersync: ^1.8.8 + powersync_attachments_helper: ^0.6.13 + powersync: ^1.8.9 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 diff --git a/packages/powersync/CHANGELOG.md b/packages/powersync/CHANGELOG.md index de2020b5..e8733016 100644 --- a/packages/powersync/CHANGELOG.md +++ b/packages/powersync/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.8.9 + + - **FIX**: issue where CRUD uploads were not triggered when the SDK reconnected to the PowerSync service after being offline. + ## 1.8.8 - Update dependency `powersync_flutter_libs` diff --git a/packages/powersync/lib/src/streaming_sync.dart b/packages/powersync/lib/src/streaming_sync.dart index 8f2515fa..57a5ff5b 100644 --- a/packages/powersync/lib/src/streaming_sync.dart +++ b/packages/powersync/lib/src/streaming_sync.dart @@ -27,6 +27,13 @@ class StreamingSyncImplementation { final Future Function() uploadCrud; + // An internal controller which is used to trigger CRUD uploads internally + // e.g. when reconnecting. + // This is only a broadcast controller since the `crudLoop` method is public + // and could potentially be called multiple times externally. + final StreamController _internalCrudTriggerController = + StreamController.broadcast(); + final Stream crudUpdateTriggerStream; final StreamController _statusStreamController = @@ -92,6 +99,9 @@ class StreamingSyncImplementation { if (_safeToClose) { _client.close(); } + + await _internalCrudTriggerController.close(); + // wait for completeAbort() to be called await future; @@ -144,7 +154,7 @@ class StreamingSyncImplementation { // On error, wait a little before retrying // When aborting, don't wait - await Future.any([Future.delayed(retryDelay), _abort!.onAbort]); + await _delayRetry(); } } } finally { @@ -155,10 +165,14 @@ class StreamingSyncImplementation { Future crudLoop() async { await uploadAllCrud(); - await for (var _ in crudUpdateTriggerStream) { - if (_abort?.aborted == true) { - break; - } + // Trigger a CRUD upload whenever the upstream trigger fires + // as-well-as whenever the sync stream reconnects. + // This has the potential (in rare cases) to affect the crudThrottleTime, + // but it should not result in excessive uploads since the + // sync reconnects are also throttled. + // The stream here is closed on abort. + await for (var _ in mergeStreams( + [crudUpdateTriggerStream, _internalCrudTriggerController.stream])) { await uploadAllCrud(); } } @@ -170,6 +184,13 @@ class StreamingSyncImplementation { while (true) { try { + // It's possible that an abort or disconnect operation could + // be followed by a `close` operation. The close would cause these + // operations, which use the DB, to throw an exception. Breaking the loop + // here prevents unnecessary potential (caught) exceptions. + if (aborted) { + break; + } // This is the first item in the FIFO CRUD queue. CrudEntry? nextCrudItem = await adapter.nextCrudItem(); if (nextCrudItem != null) { @@ -196,7 +217,7 @@ class StreamingSyncImplementation { checkedCrudItem = null; isolateLogger.warning('Data upload error', e, stacktrace); _updateStatus(uploading: false, uploadError: e); - await Future.delayed(retryDelay); + await _delayRetry(); if (!isConnected) { // Exit the upload loop if the sync stream is no longer connected break; @@ -298,6 +319,9 @@ class StreamingSyncImplementation { Future? credentialsInvalidation; bool haveInvalidated = false; + // Trigger a CRUD upload on reconnect + _internalCrudTriggerController.add(null); + await for (var line in merged) { if (aborted) { break; @@ -465,6 +489,12 @@ class StreamingSyncImplementation { yield parseStreamingSyncLine(line as Map); } } + + /// Delays the standard `retryDelay` Duration, but exits early if + /// an abort has been requested. + Future _delayRetry() async { + await Future.any([Future.delayed(retryDelay), _abort!.onAbort]); + } } /// Attempt to give a basic summary of the error for cases where the full error diff --git a/packages/powersync/lib/src/version.dart b/packages/powersync/lib/src/version.dart index 40b7b1a6..11c304d0 100644 --- a/packages/powersync/lib/src/version.dart +++ b/packages/powersync/lib/src/version.dart @@ -1 +1 @@ -const String libraryVersion = '1.8.8'; +const String libraryVersion = '1.8.9'; diff --git a/packages/powersync/pubspec.yaml b/packages/powersync/pubspec.yaml index f614c937..1cb800d9 100644 --- a/packages/powersync/pubspec.yaml +++ b/packages/powersync/pubspec.yaml @@ -1,5 +1,5 @@ name: powersync -version: 1.8.8 +version: 1.8.9 homepage: https://powersync.com repository: https://github.com/powersync-ja/powersync.dart description: PowerSync Flutter SDK - sync engine for building local-first apps. @@ -16,7 +16,7 @@ dependencies: sqlite3: ^2.4.6 universal_io: ^2.0.0 sqlite3_flutter_libs: ^0.5.23 - powersync_flutter_libs: ^0.4.1 + powersync_flutter_libs: ^0.4.2 meta: ^1.0.0 http: ^1.1.0 uuid: ^4.2.0 diff --git a/packages/powersync/test/connected_test.dart b/packages/powersync/test/connected_test.dart new file mode 100644 index 00000000..0f6d3b71 --- /dev/null +++ b/packages/powersync/test/connected_test.dart @@ -0,0 +1,136 @@ +@TestOn('!browser') +// This test uses a local server which is possible to control in Web via hybrid main, +// but this makes the test significantly more complex. +import 'dart:async'; + +import 'package:powersync/powersync.dart'; +import 'package:test/test.dart'; + +import 'server/sync_server/mock_sync_server.dart'; +import 'streaming_sync_test.dart'; +import 'utils/abstract_test_utils.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +void main() { + group('connected tests', () { + late String path; + setUp(() async { + path = testUtils.dbPath(); + }); + + tearDown(() async { + await testUtils.cleanDb(path: path); + }); + + createTestServer() async { + final testServer = TestHttpServerHelper(); + await testServer.start(); + addTearDown(() => testServer.stop()); + return testServer; + } + + test('should connect to mock PowerSync instance', () async { + final testServer = await createTestServer(); + final connector = TestConnector(() async { + return PowerSyncCredentials( + endpoint: testServer.uri.toString(), + token: 'token not used here', + expiresAt: DateTime.now()); + }); + + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: defaultSchema, + maxReaders: 3); + await db.initialize(); + + final connectedCompleter = Completer(); + + db.statusStream.listen((status) { + if (status.connected) { + connectedCompleter.complete(); + } + }); + + // Add a basic command for the test server to send + testServer.addEvent('{"token_expires_in": 3600}\n'); + + await db.connect(connector: connector); + await connectedCompleter.future; + + expect(db.connected, isTrue); + await db.disconnect(); + }); + + test('should trigger uploads when connection is re-established', () async { + int uploadCounter = 0; + Completer uploadTriggeredCompleter = Completer(); + final testServer = await createTestServer(); + final connector = TestConnector(() async { + return PowerSyncCredentials( + endpoint: testServer.uri.toString(), + token: 'token not used here', + expiresAt: DateTime.now()); + }, uploadData: (database) async { + uploadCounter++; + uploadTriggeredCompleter.complete(); + throw Exception('No uploads occur here'); + }); + + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: defaultSchema, + maxReaders: 3); + await db.initialize(); + + // Create an item which should trigger an upload. + await db.execute( + 'INSERT INTO customers (id, name) VALUES (uuid(), ?)', ['steven']); + + // Create a new completer to await the next upload + uploadTriggeredCompleter = Completer(); + + // Connect the PowerSync instance + final connectedCompleter = Completer(); + // The first connection attempt will fail + final connectedErroredCompleter = Completer(); + + db.statusStream.listen((status) { + if (status.connected && !connectedCompleter.isCompleted) { + connectedCompleter.complete(); + } + if (status.downloadError != null && + !connectedErroredCompleter.isCompleted) { + connectedErroredCompleter.complete(); + } + }); + + // The first command will not be valid, this simulates a failed connection + testServer.addEvent('asdf\n'); + await db.connect(connector: connector); + + // The connect operation should have triggered an upload (even though it fails to connect) + await uploadTriggeredCompleter.future; + expect(uploadCounter, equals(1)); + // Create a new completer for the next iteration + uploadTriggeredCompleter = Completer(); + + // Connection attempt should initially fail + await connectedErroredCompleter.future; + expect(db.currentStatus.anyError, isNotNull); + + // Now send a valid command. Which will result in successful connection + await testServer.clearEvents(); + testServer.addEvent('{"token_expires_in": 3600}\n'); + await connectedCompleter.future; + expect(db.connected, isTrue); + + await uploadTriggeredCompleter.future; + expect(uploadCounter, equals(2)); + + await db.disconnect(); + }); + }); +} diff --git a/packages/powersync/test/server/sync_server/mock_sync_server.dart b/packages/powersync/test/server/sync_server/mock_sync_server.dart new file mode 100644 index 00000000..0f1c8f49 --- /dev/null +++ b/packages/powersync/test/server/sync_server/mock_sync_server.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_router/shelf_router.dart'; + +// A basic Mock PowerSync service server which queues commands +// which clients can receive via connecting to the `/sync/stream` route. +// This assumes only one client will ever be connected at a time. +class TestHttpServerHelper { + // Use a queued stream to make tests easier. + StreamController _controller = StreamController(); + late HttpServer _server; + Uri get uri => Uri.parse('http://localhost:${_server.port}'); + + Future start() async { + final router = Router() + ..post('/sync/stream', (Request request) async { + // Respond immediately with a stream + return Response.ok(_controller.stream.transform(utf8.encoder), + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + context: { + "shelf.io.buffer_output": false + }); + }); + + _server = await io.serve(router.call, 'localhost', 0); + print('Test server running at ${_server.address}:${_server.port}'); + } + + // Queue events which will be sent to connected clients. + void addEvent(String data) { + _controller.add(data); + } + + // Clear events. We rely on a buffered controller here. Create a new controller + // in order to clear the buffer. + Future clearEvents() async { + await _controller.close(); + _controller = StreamController(); + } + + Future stop() async { + await _controller.close(); + await _server.close(); + } +} diff --git a/packages/powersync/test/streaming_sync_test.dart b/packages/powersync/test/streaming_sync_test.dart index 6d1e9e0d..92b03dd3 100644 --- a/packages/powersync/test/streaming_sync_test.dart +++ b/packages/powersync/test/streaming_sync_test.dart @@ -13,8 +13,11 @@ final testUtils = TestUtils(); class TestConnector extends PowerSyncBackendConnector { final Function _fetchCredentials; + final Future Function(PowerSyncDatabase)? _uploadData; - TestConnector(this._fetchCredentials); + TestConnector(this._fetchCredentials, + {Future Function(PowerSyncDatabase)? uploadData}) + : _uploadData = uploadData; @override Future fetchCredentials() { @@ -22,7 +25,9 @@ class TestConnector extends PowerSyncBackendConnector { } @override - Future uploadData(PowerSyncDatabase database) async {} + Future uploadData(PowerSyncDatabase database) async { + await _uploadData?.call(database); + } } void main() { diff --git a/packages/powersync/test/upload_test.dart b/packages/powersync/test/upload_test.dart index 20ad04f7..c3fa603e 100644 --- a/packages/powersync/test/upload_test.dart +++ b/packages/powersync/test/upload_test.dart @@ -66,7 +66,9 @@ void main() { powersync = await testUtils.setupPowerSync(path: path, logger: testWarningLogger); - powersync.retryDelay = Duration(milliseconds: 0); + // Use a short retry delay here. + // A zero retry delay makes this test unstable, since it expects `2` error logs later. + powersync.retryDelay = Duration(milliseconds: 100); var connector = TestConnector(credentialsCallback, uploadData); powersync.connect(connector: connector); diff --git a/packages/powersync/test/utils/abstract_test_utils.dart b/packages/powersync/test/utils/abstract_test_utils.dart index efae5964..773dafb6 100644 --- a/packages/powersync/test/utils/abstract_test_utils.dart +++ b/packages/powersync/test/utils/abstract_test_utils.dart @@ -26,8 +26,8 @@ final testLogger = _makeTestLogger(); final testWarningLogger = _makeTestLogger(level: Level.WARNING); -Logger _makeTestLogger({Level level = Level.ALL}) { - final logger = Logger.detached('PowerSync Tests'); +Logger _makeTestLogger({Level level = Level.ALL, String? name}) { + final logger = Logger.detached(name ?? 'PowerSync Tests'); logger.level = level; logger.onRecord.listen((record) { print( @@ -53,11 +53,11 @@ Logger _makeTestLogger({Level level = Level.ALL}) { } abstract class AbstractTestUtils { + String get _testName => Invoker.current!.liveTest.test.name; + String dbPath() { - final test = Invoker.current!.liveTest; - var testName = test.test.name; var testShortName = - testName.replaceAll(RegExp(r'[\s\./]'), '_').toLowerCase(); + _testName.replaceAll(RegExp(r'[\s\./]'), '_').toLowerCase(); var dbName = "test-db/$testShortName.db"; return dbName; } @@ -74,7 +74,8 @@ abstract class AbstractTestUtils { Future setupPowerSync( {String? path, Schema? schema, Logger? logger}) async { final db = PowerSyncDatabase.withFactory(await testFactory(path: path), - schema: schema ?? defaultSchema, logger: logger ?? testLogger); + schema: schema ?? defaultSchema, + logger: logger ?? _makeTestLogger(name: _testName)); await db.initialize(); return db; } diff --git a/packages/powersync_attachments_helper/CHANGELOG.md b/packages/powersync_attachments_helper/CHANGELOG.md index 8444c333..fd46da63 100644 --- a/packages/powersync_attachments_helper/CHANGELOG.md +++ b/packages/powersync_attachments_helper/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.13 + + - Update a dependency to the latest release. + ## 0.6.12 - Update a dependency to the latest release. diff --git a/packages/powersync_attachments_helper/pubspec.yaml b/packages/powersync_attachments_helper/pubspec.yaml index 49a19eb4..e38e05c5 100644 --- a/packages/powersync_attachments_helper/pubspec.yaml +++ b/packages/powersync_attachments_helper/pubspec.yaml @@ -1,6 +1,6 @@ name: powersync_attachments_helper description: A helper library for handling attachments when using PowerSync. -version: 0.6.12 +version: 0.6.13 repository: https://github.com/powersync-ja/powersync.dart homepage: https://www.powersync.com/ environment: @@ -10,7 +10,7 @@ dependencies: flutter: sdk: flutter - powersync: ^1.8.8 + powersync: ^1.8.9 logging: ^1.2.0 sqlite_async: ^0.9.1 path_provider: ^2.0.13 diff --git a/packages/powersync_flutter_libs/CHANGELOG.md b/packages/powersync_flutter_libs/CHANGELOG.md index f5b7e354..d14eb195 100644 --- a/packages/powersync_flutter_libs/CHANGELOG.md +++ b/packages/powersync_flutter_libs/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.2 + + - Update a dependency to the latest release. + ## 0.4.1 - powersync-sqlite-core v0.3.4 diff --git a/packages/powersync_flutter_libs/pubspec.yaml b/packages/powersync_flutter_libs/pubspec.yaml index 03bc8950..cb50688b 100644 --- a/packages/powersync_flutter_libs/pubspec.yaml +++ b/packages/powersync_flutter_libs/pubspec.yaml @@ -1,6 +1,6 @@ name: powersync_flutter_libs description: PowerSync core binaries for the PowerSync Flutter SDK. Needs to be included for Flutter apps. -version: 0.4.1 +version: 0.4.2 repository: https://github.com/powersync-ja/powersync.dart homepage: https://www.powersync.com/