diff --git a/.github/workflows/prepare_wasm.yml b/.github/workflows/prepare_wasm.yml index b38aa7bc..5da977c1 100644 --- a/.github/workflows/prepare_wasm.yml +++ b/.github/workflows/prepare_wasm.yml @@ -25,7 +25,7 @@ jobs: uses: dart-lang/setup-dart@v1 - name: Setup macOS build dependencies if: steps.cache_build.outputs.cache-hit != 'true' - run: brew install cmake llvm lld binaryen wasi-libc wasi-runtimes + run: brew install llvm lld binaryen wasi-libc wasi-runtimes - name: Compile sqlite3.wasm on macOS if: steps.cache_build.outputs.cache-hit != 'true' working-directory: packages/sqlite3_wasm_build diff --git a/.gitignore b/.gitignore index c47e4141..95fba1e8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ pubspec_overrides.yaml .flutter-plugins-dependencies .flutter-plugins build +**/doc/api +.build # Shared assets assets/* diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ffa2cc..e76e3a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,159 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-10-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync` - `v1.16.1`](#powersync---v1161) + - [`powersync_core` - `v1.6.1`](#powersync_core---v161) + - [`powersync_sqlcipher` - `v0.1.13`](#powersync_sqlcipher---v0113) + +--- + +#### `powersync` - `v1.16.1` + + - Web: Fix decoding sync streams on status. + +#### `powersync_core` - `v1.6.1` + + - Web: Fix decoding sync streams on status. + + - **DOCS**: Point to uses in example. ([4f4da24e](https://github.com/powersync-ja/powersync.dart/commit/4f4da24e580dec6b1d29a5e0907b83ba7c55e3d8)) + +#### `powersync_sqlcipher` - `v0.1.13` + + - Web: Fix decoding sync streams on status. + + +## 2025-10-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync_attachments_helper` - `v0.6.20`](#powersync_attachments_helper---v0620) + - [`powersync` - `v1.16.0`](#powersync---v1160) + - [`powersync_core` - `v1.6.0`](#powersync_core---v160) + - [`powersync_flutter_libs` - `v0.4.12`](#powersync_flutter_libs---v0412) + - [`powersync_sqlcipher` - `v0.1.12`](#powersync_sqlcipher---v0112) + +--- + +#### `powersync_attachments_helper` - `v0.6.20` + + - Add note about new attachment queue system in core package. + +#### `powersync` - `v1.16.0` +#### `powersync_core` - `v1.6.0` +#### `powersync_sqlcipher` - `v0.1.12` + +- Add `getCrudTransactions()` returning a stream of completed transactions for uploads. +- Add experimental support for [sync streams](https://docs.powersync.com/usage/sync-streams). +- Add new attachments helper implementation in `package:powersync_core/attachments/attachments.dart`. +- Add SwiftPM support. +- Add support for compiling `powersync_core` with `build_web_compilers`. + +#### `powersync_flutter_libs` - `v0.4.12` + + - Update core extension. + - Add support for SwiftPM. + +## 2025-08-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync_attachments_helper` - `v0.6.19`](#powersync_attachments_helper---v0619) + +--- + +#### `powersync_attachments_helper` - `v0.6.19` + + - Remove direct dependency on `sqlite_async`. + + +## 2025-08-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync_core` - `v1.5.2`](#powersync_core---v152) + - [`powersync` - `v1.15.2`](#powersync---v1152) + - [`powersync_sqlcipher` - `v0.1.11+1`](#powersync_sqlcipher---v01111) + +--- + +#### `powersync_core` - `v1.5.2` + + - Fix excessive memory consumption during large sync. + +#### `powersync` - `v1.15.2` + + - Fix excessive memory consumption during large sync. + +#### `powersync_sqlcipher` - `v0.1.11+1` + + - Fix excessive memory consumption during large sync. + + +## 2025-08-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`powersync_core` - `v1.5.1`](#powersync_core---v151) + - [`powersync` - `v1.15.1`](#powersync---v1151) + - [`powersync_sqlcipher` - `v0.1.11`](#powersync_sqlcipher---v0111) + - [`powersync_flutter_libs` - `v0.4.11`](#powersync_flutter_libs---v0411) + +--- + +#### `powersync_core` - `v1.5.1` +#### `powersync` - `v1.15.1` +#### `powersync_sqlcipher` - `v0.1.11` +#### `powersync_flutter_libs` - `v0.4.11` + + - Support latest versions of `package:sqlite3` and `package:sqlite_async`. + - Stream client: Improve `disconnect()` while a connection is being opened. + - Stream client: Support binary sync lines with Rust client and compatible PowerSync service versions. + - Sync client: Improve parsing error responses. + ## 2025-07-17 ### Changes diff --git a/demos/benchmarks/ios/Podfile b/demos/benchmarks/ios/Podfile index d97f17e2..3e44f9c6 100644 --- a/demos/benchmarks/ios/Podfile +++ b/demos/benchmarks/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/benchmarks/ios/Podfile.lock b/demos/benchmarks/ios/Podfile.lock index 7a167d34..9d0fed8c 100644 --- a/demos/benchmarks/ios/Podfile.lock +++ b/demos/benchmarks/ios/Podfile.lock @@ -3,10 +3,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - sqlite3 (3.49.2): - sqlite3/common (= 3.49.2) - sqlite3/common (3.49.2) @@ -52,13 +52,13 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 +PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 COCOAPODS: 1.16.2 diff --git a/demos/benchmarks/macos/Podfile b/demos/benchmarks/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/benchmarks/macos/Podfile +++ b/demos/benchmarks/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/benchmarks/macos/Podfile.lock b/demos/benchmarks/macos/Podfile.lock index b1925cd1..76db1f9b 100644 --- a/demos/benchmarks/macos/Podfile.lock +++ b/demos/benchmarks/macos/Podfile.lock @@ -3,10 +3,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - sqlite3 (3.49.2): - sqlite3/common (= 3.49.2) - sqlite3/common (3.49.2) @@ -52,13 +52,13 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/benchmarks/pubspec.lock b/demos/benchmarks/pubspec.lock index 78420348..b45942df 100644 --- a/demos/benchmarks/pubspec.lock +++ b/demos/benchmarks/pubspec.lock @@ -111,10 +111,10 @@ packages: dependency: "direct main" description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" http_parser: dependency: transitive description: @@ -135,26 +135,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -281,21 +281,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.2" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.2" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.11" pub_semver: dependency: transitive description: @@ -337,10 +337,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 url: "https://pub.dev" source: hosted - version: "2.7.5" + version: "2.9.0" sqlite3_flutter_libs: dependency: transitive description: @@ -353,18 +353,18 @@ packages: dependency: transitive description: name: sqlite3_web - sha256: "967e076442f7e1233bd7241ca61f3efe4c7fc168dac0f38411bdb3bdf471eb3c" + sha256: "0f6ebcb4992d1892ac5c8b5ecd22a458ab9c5eb6428b11ae5ecb5d63545844da" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.2" sqlite_async: dependency: "direct main" description: name: sqlite_async - sha256: a60e8d5c8df8e694933bd5a312c38393e79ad77d784bb91c6f38ba627bfb7aec + sha256: "6116bfc6aef6ce77730b478385ba4a58873df45721f6a9bc6ffabf39b6576e36" url: "https://pub.dev" source: hosted - version: "0.11.4" + version: "0.12.1" stack_trace: dependency: transitive description: @@ -401,10 +401,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -433,10 +433,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -470,5 +470,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.7.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/demos/benchmarks/pubspec.yaml b/demos/benchmarks/pubspec.yaml index d2bb82f6..aea63c20 100644 --- a/demos/benchmarks/pubspec.yaml +++ b/demos/benchmarks/pubspec.yaml @@ -10,12 +10,12 @@ environment: dependencies: flutter: sdk: flutter - powersync: ^1.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 universal_io: ^2.2.2 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 http: ^1.2.2 dev_dependencies: diff --git a/demos/django-todolist/ios/Podfile b/demos/django-todolist/ios/Podfile index e9f73048..2c1e086a 100644 --- a/demos/django-todolist/ios/Podfile +++ b/demos/django-todolist/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/django-todolist/ios/Podfile.lock b/demos/django-todolist/ios/Podfile.lock index 92649cb7..8bfa9ae3 100644 --- a/demos/django-todolist/ios/Podfile.lock +++ b/demos/django-todolist/ios/Podfile.lock @@ -3,10 +3,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -58,14 +58,14 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 -PODFILE CHECKSUM: f7b3cb7384a2d5da4b22b090e1f632de7f377987 +PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f COCOAPODS: 1.16.2 diff --git a/demos/django-todolist/lib/widgets/guard_by_sync.dart b/demos/django-todolist/lib/widgets/guard_by_sync.dart index 000b5c8d..b65986b0 100644 --- a/demos/django-todolist/lib/widgets/guard_by_sync.dart +++ b/demos/django-todolist/lib/widgets/guard_by_sync.dart @@ -7,9 +7,9 @@ import 'package:powersync_django_todolist_demo/powersync.dart'; class GuardBySync extends StatelessWidget { final Widget child; - /// When set, wait only for a complete sync within the [BucketPriority] + /// When set, wait only for a complete sync within the [StreamPriority] /// instead of a full sync. - final BucketPriority? priority; + final StreamPriority? priority; const GuardBySync({ super.key, diff --git a/demos/django-todolist/macos/Podfile b/demos/django-todolist/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/django-todolist/macos/Podfile +++ b/demos/django-todolist/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/django-todolist/macos/Podfile.lock b/demos/django-todolist/macos/Podfile.lock index 249a5b49..c6edd223 100644 --- a/demos/django-todolist/macos/Podfile.lock +++ b/demos/django-todolist/macos/Podfile.lock @@ -3,10 +3,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -58,14 +58,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/django-todolist/pubspec.lock b/demos/django-todolist/pubspec.lock index e627551d..881d6d47 100644 --- a/demos/django-todolist/pubspec.lock +++ b/demos/django-todolist/pubspec.lock @@ -294,21 +294,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/django-todolist/pubspec.yaml b/demos/django-todolist/pubspec.yaml index 77d13739..4c1cacff 100644 --- a/demos/django-todolist/pubspec.yaml +++ b/demos/django-todolist/pubspec.yaml @@ -10,11 +10,11 @@ environment: dependencies: flutter: sdk: flutter - powersync: ^1.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 http: ^1.2.1 shared_preferences: ^2.2.3 diff --git a/demos/firebase-nodejs-todolist/ios/Podfile.lock b/demos/firebase-nodejs-todolist/ios/Podfile.lock index e3b3429e..f5b33ded 100644 --- a/demos/firebase-nodejs-todolist/ios/Podfile.lock +++ b/demos/firebase-nodejs-todolist/ios/Podfile.lock @@ -58,10 +58,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - RecaptchaInterop (101.0.0) - shared_preferences_foundation (0.0.1): - Flutter @@ -148,12 +148,12 @@ SPEC CHECKSUMS: FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: fc75fc972958dceedee61cb662ae1da7a83a91cf path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 diff --git a/demos/firebase-nodejs-todolist/macos/Podfile b/demos/firebase-nodejs-todolist/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/firebase-nodejs-todolist/macos/Podfile +++ b/demos/firebase-nodejs-todolist/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/firebase-nodejs-todolist/macos/Podfile.lock b/demos/firebase-nodejs-todolist/macos/Podfile.lock index f971793a..fc4bb3aa 100644 --- a/demos/firebase-nodejs-todolist/macos/Podfile.lock +++ b/demos/firebase-nodejs-todolist/macos/Podfile.lock @@ -1,28 +1,91 @@ PODS: - app_links (1.0.0): - FlutterMacOS + - Firebase/Auth (11.10.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 11.10.0) + - Firebase/CoreOnly (11.10.0): + - FirebaseCore (~> 11.10.0) + - firebase_auth (5.5.3): + - Firebase/Auth (~> 11.10.0) + - Firebase/CoreOnly (~> 11.10.0) + - firebase_core + - FlutterMacOS + - firebase_core (3.13.0): + - Firebase/CoreOnly (~> 11.10.0) + - FlutterMacOS + - FirebaseAppCheckInterop (11.15.0) + - FirebaseAuth (11.10.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.10.0) + - FirebaseCoreExtension (~> 11.10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) + - RecaptchaInterop (~> 101.0) + - FirebaseAuthInterop (11.15.0) + - FirebaseCore (11.10.0): + - FirebaseCoreInternal (~> 11.10.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreExtension (11.10.0): + - FirebaseCore (~> 11.10.0) + - FirebaseCoreInternal (11.10.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" - FlutterMacOS (1.0.0) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (4.5.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - powersync-sqlite-core (0.4.5) + - powersync_flutter_libs (0.0.1): + - FlutterMacOS + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sign_in_with_apple (0.0.1): - - FlutterMacOS - - sqlite3 (3.41.2): - - sqlite3/common (= 3.41.2) - - sqlite3/common (3.41.2) - - sqlite3/fts5 (3.41.2): + - sqlite3 (3.49.2): + - sqlite3/common (= 3.49.2) + - sqlite3/common (3.49.2) + - sqlite3/dbstatvtab (3.49.2): - sqlite3/common - - sqlite3/perf-threadsafe (3.41.2): + - sqlite3/fts5 (3.49.2): - sqlite3/common - - sqlite3/rtree (3.41.2): + - sqlite3/math (3.49.2): + - sqlite3/common + - sqlite3/perf-threadsafe (3.49.2): + - sqlite3/common + - sqlite3/rtree (3.49.2): - sqlite3/common - sqlite3_flutter_libs (0.0.1): + - Flutter - FlutterMacOS - - sqlite3 (~> 3.41.2) + - sqlite3 (~> 3.49.1) + - sqlite3/dbstatvtab - sqlite3/fts5 + - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree - url_launcher_macos (0.0.1): @@ -30,43 +93,71 @@ PODS: DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - firebase_auth (from `Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - powersync_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) SPEC REPOS: trunk: + - Firebase + - FirebaseAppCheckInterop + - FirebaseAuth + - FirebaseAuthInterop + - FirebaseCore + - FirebaseCoreExtension + - FirebaseCoreInternal + - GoogleUtilities + - GTMSessionFetcher + - powersync-sqlite-core - sqlite3 EXTERNAL SOURCES: app_links: :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + firebase_auth: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_auth/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos FlutterMacOS: :path: Flutter/ephemeral path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + powersync_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - sign_in_with_apple: - :path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727 - sqlite3: fd89671d969f3e73efe503ce203e28b016b58f68 - sqlite3_flutter_libs: 00a50503d69f7ab0fe85a5ff25b33082f4df4ce9 - url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 + app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d + Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 + firebase_auth: d9a868727e64a42540f791ffb5a656afa0c29a58 + firebase_core: efd50ad8177dc489af1b9163a560359cf1b30597 + FirebaseAppCheckInterop: 06fe5a3799278ae4667e6c432edd86b1030fa3df + FirebaseAuth: c4146bdfdc87329f9962babd24dae89373f49a32 + FirebaseAuthInterop: 7087d7a4ee4bc4de019b2d0c240974ed5d89e2fd + FirebaseCore: 8344daef5e2661eb004b177488d6f9f0f24251b7 + FirebaseCoreExtension: 6f357679327f3614e995dc7cf3f2d600bdc774ac + FirebaseCoreInternal: ef4505d2afb1d0ebbc33162cb3795382904b5679 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: fc75fc972958dceedee61cb662ae1da7a83a91cf + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 + sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.12.1 +COCOAPODS: 1.16.2 diff --git a/demos/firebase-nodejs-todolist/pubspec.lock b/demos/firebase-nodejs-todolist/pubspec.lock index 96bd7e21..5eead021 100644 --- a/demos/firebase-nodejs-todolist/pubspec.lock +++ b/demos/firebase-nodejs-todolist/pubspec.lock @@ -430,21 +430,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/firebase-nodejs-todolist/pubspec.yaml b/demos/firebase-nodejs-todolist/pubspec.yaml index e86b714a..de6ea1fe 100644 --- a/demos/firebase-nodejs-todolist/pubspec.yaml +++ b/demos/firebase-nodejs-todolist/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter - powersync: ^1.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 diff --git a/demos/supabase-anonymous-auth/ios/Podfile b/demos/supabase-anonymous-auth/ios/Podfile index 656de635..2c1e086a 100644 --- a/demos/supabase-anonymous-auth/ios/Podfile +++ b/demos/supabase-anonymous-auth/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-anonymous-auth/ios/Podfile.lock b/demos/supabase-anonymous-auth/ios/Podfile.lock index 6553952e..77f7310a 100644 --- a/demos/supabase-anonymous-auth/ios/Podfile.lock +++ b/demos/supabase-anonymous-auth/ios/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 13e359f40c4925bcdf0c1bfa13aeba35011fde30 +PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f COCOAPODS: 1.16.2 diff --git a/demos/supabase-anonymous-auth/macos/Podfile b/demos/supabase-anonymous-auth/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-anonymous-auth/macos/Podfile +++ b/demos/supabase-anonymous-auth/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-anonymous-auth/macos/Podfile.lock b/demos/supabase-anonymous-auth/macos/Podfile.lock index f32a7411..6983b2da 100644 --- a/demos/supabase-anonymous-auth/macos/Podfile.lock +++ b/demos/supabase-anonymous-auth/macos/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-anonymous-auth/pubspec.lock b/demos/supabase-anonymous-auth/pubspec.lock index e3838774..48c25b82 100644 --- a/demos/supabase-anonymous-auth/pubspec.lock +++ b/demos/supabase-anonymous-auth/pubspec.lock @@ -374,21 +374,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/supabase-anonymous-auth/pubspec.yaml b/demos/supabase-anonymous-auth/pubspec.yaml index 7d4604d3..b4f44bcb 100644 --- a/demos/supabase-anonymous-auth/pubspec.yaml +++ b/demos/supabase-anonymous-auth/pubspec.yaml @@ -11,12 +11,12 @@ dependencies: flutter: sdk: flutter - powersync: ^1.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 logging: ^1.2.0 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 universal_io: ^2.2.2 dev_dependencies: diff --git a/demos/supabase-edge-function-auth/ios/Podfile b/demos/supabase-edge-function-auth/ios/Podfile index 656de635..2c1e086a 100644 --- a/demos/supabase-edge-function-auth/ios/Podfile +++ b/demos/supabase-edge-function-auth/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-edge-function-auth/ios/Podfile.lock b/demos/supabase-edge-function-auth/ios/Podfile.lock index 6553952e..77f7310a 100644 --- a/demos/supabase-edge-function-auth/ios/Podfile.lock +++ b/demos/supabase-edge-function-auth/ios/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 13e359f40c4925bcdf0c1bfa13aeba35011fde30 +PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f COCOAPODS: 1.16.2 diff --git a/demos/supabase-edge-function-auth/macos/Podfile b/demos/supabase-edge-function-auth/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-edge-function-auth/macos/Podfile +++ b/demos/supabase-edge-function-auth/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-edge-function-auth/macos/Podfile.lock b/demos/supabase-edge-function-auth/macos/Podfile.lock index f32a7411..6983b2da 100644 --- a/demos/supabase-edge-function-auth/macos/Podfile.lock +++ b/demos/supabase-edge-function-auth/macos/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-edge-function-auth/pubspec.lock b/demos/supabase-edge-function-auth/pubspec.lock index e3838774..48c25b82 100644 --- a/demos/supabase-edge-function-auth/pubspec.lock +++ b/demos/supabase-edge-function-auth/pubspec.lock @@ -374,21 +374,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/supabase-edge-function-auth/pubspec.yaml b/demos/supabase-edge-function-auth/pubspec.yaml index 4d681973..f1f5ddcb 100644 --- a/demos/supabase-edge-function-auth/pubspec.yaml +++ b/demos/supabase-edge-function-auth/pubspec.yaml @@ -11,12 +11,12 @@ dependencies: flutter: sdk: flutter - powersync: ^1.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 logging: ^1.2.0 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 universal_io: ^2.2.2 dev_dependencies: diff --git a/demos/supabase-simple-chat/ios/Podfile b/demos/supabase-simple-chat/ios/Podfile index 164df534..3e44f9c6 100644 --- a/demos/supabase-simple-chat/ios/Podfile +++ b/demos/supabase-simple-chat/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-simple-chat/ios/Podfile.lock b/demos/supabase-simple-chat/ios/Podfile.lock index 12271547..6108d2f1 100644 --- a/demos/supabase-simple-chat/ios/Podfile.lock +++ b/demos/supabase-simple-chat/ios/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 7be2f5f74864d463a8ad433546ed1de7e0f29aef +PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 COCOAPODS: 1.16.2 diff --git a/demos/supabase-simple-chat/macos/Podfile b/demos/supabase-simple-chat/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-simple-chat/macos/Podfile +++ b/demos/supabase-simple-chat/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-simple-chat/macos/Podfile.lock b/demos/supabase-simple-chat/macos/Podfile.lock index f32a7411..6983b2da 100644 --- a/demos/supabase-simple-chat/macos/Podfile.lock +++ b/demos/supabase-simple-chat/macos/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-simple-chat/pubspec.lock b/demos/supabase-simple-chat/pubspec.lock index 8ea11c3d..d65ac498 100644 --- a/demos/supabase-simple-chat/pubspec.lock +++ b/demos/supabase-simple-chat/pubspec.lock @@ -390,21 +390,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/supabase-simple-chat/pubspec.yaml b/demos/supabase-simple-chat/pubspec.yaml index 15afa2fd..463278d2 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.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 diff --git a/demos/supabase-todolist-drift/ios/Flutter/AppFrameworkInfo.plist b/demos/supabase-todolist-drift/ios/Flutter/AppFrameworkInfo.plist index 7c569640..1dc6cf76 100644 --- a/demos/supabase-todolist-drift/ios/Flutter/AppFrameworkInfo.plist +++ b/demos/supabase-todolist-drift/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/demos/supabase-todolist-drift/ios/Podfile b/demos/supabase-todolist-drift/ios/Podfile deleted file mode 100644 index d97f17e2..00000000 --- a/demos/supabase-todolist-drift/ios/Podfile +++ /dev/null @@ -1,44 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/demos/supabase-todolist-drift/ios/Podfile.lock b/demos/supabase-todolist-drift/ios/Podfile.lock deleted file mode 100644 index 1af06a74..00000000 --- a/demos/supabase-todolist-drift/ios/Podfile.lock +++ /dev/null @@ -1,89 +0,0 @@ -PODS: - - app_links (0.0.2): - - Flutter - - camera_avfoundation (0.0.1): - - Flutter - - Flutter (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - powersync-sqlite-core (0.4.2) - - powersync_flutter_libs (0.0.1): - - Flutter - - powersync-sqlite-core (~> 0.4.2) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): - - sqlite3/common - - sqlite3/fts5 (3.49.2): - - sqlite3/common - - sqlite3/math (3.49.2): - - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): - - sqlite3/common - - sqlite3/rtree (3.49.2): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.49.1) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - url_launcher_ios (0.0.1): - - Flutter - -DEPENDENCIES: - - app_links (from `.symlinks/plugins/app_links/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - - Flutter (from `Flutter`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - powersync_flutter_libs (from `.symlinks/plugins/powersync_flutter_libs/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - -SPEC REPOS: - trunk: - - powersync-sqlite-core - - sqlite3 - -EXTERNAL SOURCES: - app_links: - :path: ".symlinks/plugins/app_links/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" - Flutter: - :path: Flutter - path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" - powersync_flutter_libs: - :path: ".symlinks/plugins/powersync_flutter_libs/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqlite3_flutter_libs: - :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - -SPEC CHECKSUMS: - app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 - camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d - -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 - -COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.pbxproj b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.pbxproj index 94547304..ad2605c8 100644 --- a/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.pbxproj @@ -8,11 +8,10 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 2AA95F4BD1106733AA65B3D1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C7F26FE8A2AE7EB1DCD03A63 /* Pods_RunnerTests.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 6B669B18D5922DF2E9FF3231 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DC37C372644D968F6312083 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -44,16 +43,12 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 15AA756A195A0498929C7AB8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 4ABD541213112FCC0D84BBA6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 53F1849573A471693AAF7757 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 5AB1DDF473ECE5996A267CFC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 5DC37C372644D968F6312083 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -62,9 +57,6 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A15106F56CD7643EC0938932 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - AC1EC468FE1100F19B83C528 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - C7F26FE8A2AE7EB1DCD03A63 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,7 +64,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2AA95F4BD1106733AA65B3D1 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,7 +71,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6B669B18D5922DF2E9FF3231 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,23 +86,10 @@ path = RunnerTests; sourceTree = ""; }; - 775FDDC091D1B0F1643D5E75 /* Pods */ = { - isa = PBXGroup; - children = ( - 5AB1DDF473ECE5996A267CFC /* Pods-Runner.debug.xcconfig */, - A15106F56CD7643EC0938932 /* Pods-Runner.release.xcconfig */, - AC1EC468FE1100F19B83C528 /* Pods-Runner.profile.xcconfig */, - 53F1849573A471693AAF7757 /* Pods-RunnerTests.debug.xcconfig */, - 15AA756A195A0498929C7AB8 /* Pods-RunnerTests.release.xcconfig */, - 4ABD541213112FCC0D84BBA6 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -127,8 +105,6 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - 775FDDC091D1B0F1643D5E75 /* Pods */, - CD2D4C39902602D7512B2E46 /* Frameworks */, ); sourceTree = ""; }; @@ -156,15 +132,6 @@ path = Runner; sourceTree = ""; }; - CD2D4C39902602D7512B2E46 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 5DC37C372644D968F6312083 /* Pods_Runner.framework */, - C7F26FE8A2AE7EB1DCD03A63 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -172,7 +139,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 1BFE7CDE33D6F794EB1D704F /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 2127B9F9BB1BBDC30AACA133 /* Frameworks */, @@ -191,20 +157,21 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 7978750E9AC7A32658AF3F71 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 25F5BBE07D381880BF0BA164 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +205,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -270,45 +240,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1BFE7CDE33D6F794EB1D704F /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 25F5BBE07D381880BF0BA164 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -325,28 +256,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 7978750E9AC7A32658AF3F71 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -455,7 +364,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -488,7 +397,6 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 53F1849573A471693AAF7757 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -506,7 +414,6 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 15AA756A195A0498929C7AB8 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -522,7 +429,6 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4ABD541213112FCC0D84BBA6 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -585,7 +491,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -636,7 +542,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -726,6 +632,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..8e5eb05f --- /dev/null +++ b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,22 @@ +{ + "pins" : [ + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "a8d28afef08ad8faa4ee9ef7845f61c2e8ac5810" + } + }, + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "00776db5157c8648671b00e6673603144fafbfeb", + "version" : "0.4.5" + } + } + ], + "version" : 2 +} diff --git a/demos/supabase-todolist-drift/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5df..c3fedb29 100644 --- a/demos/supabase-todolist-drift/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/demos/supabase-todolist-drift/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + diff --git a/demos/supabase-todolist-drift/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/demos/supabase-todolist-drift/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..0c12c1e5 --- /dev/null +++ b/demos/supabase-todolist-drift/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,22 @@ +{ + "pins" : [ + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "a268235ae86718e66d6a29feef3bd22c772eb82b" + } + }, + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" + } + } + ], + "version" : 2 +} diff --git a/demos/supabase-todolist-drift/ios/Runner/AppDelegate.swift b/demos/supabase-todolist-drift/ios/Runner/AppDelegate.swift index 9074fee9..62666446 100644 --- a/demos/supabase-todolist-drift/ios/Runner/AppDelegate.swift +++ b/demos/supabase-todolist-drift/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Flutter import UIKit -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/demos/supabase-todolist-drift/lib/powersync/powersync.dart b/demos/supabase-todolist-drift/lib/powersync/powersync.dart index cfd73336..cb327bfd 100644 --- a/demos/supabase-todolist-drift/lib/powersync/powersync.dart +++ b/demos/supabase-todolist-drift/lib/powersync/powersync.dart @@ -47,18 +47,19 @@ Future powerSyncInstance(Ref ref) async { return db; } -final _syncStatusInternal = StreamProvider((ref) { +final _syncStatusInternal = StreamProvider((ref) { return Stream.fromFuture( ref.watch(powerSyncInstanceProvider.future), - ).asyncExpand((db) => db.statusStream).startWith(const SyncStatus()); + ).asyncExpand((db) => db.statusStream).startWith(null); }); final syncStatus = Provider((ref) { + // ignore: invalid_use_of_internal_member return ref.watch(_syncStatusInternal).value ?? const SyncStatus(); }); @riverpod -bool didCompleteSync(Ref ref, [BucketPriority? priority]) { +bool didCompleteSync(Ref ref, [StreamPriority? priority]) { final status = ref.watch(syncStatus); if (priority != null) { return status.statusForPriority(priority).hasSynced ?? false; diff --git a/demos/supabase-todolist-drift/lib/screens/lists.dart b/demos/supabase-todolist-drift/lib/screens/lists.dart index ebaa0857..c995a275 100644 --- a/demos/supabase-todolist-drift/lib/screens/lists.dart +++ b/demos/supabase-todolist-drift/lib/screens/lists.dart @@ -35,7 +35,7 @@ final class _ListsWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final lists = ref.watch(listsNotifierProvider); - final didSync = ref.watch(didCompleteSyncProvider(BucketPriority(1))); + final didSync = ref.watch(didCompleteSyncProvider(StreamPriority(1))); if (!didSync) { return const Text('Busy with sync...'); diff --git a/demos/supabase-todolist-drift/macos/Podfile b/demos/supabase-todolist-drift/macos/Podfile deleted file mode 100644 index c795730d..00000000 --- a/demos/supabase-todolist-drift/macos/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -platform :osx, '10.14' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/demos/supabase-todolist-drift/macos/Podfile.lock b/demos/supabase-todolist-drift/macos/Podfile.lock deleted file mode 100644 index f32a7411..00000000 --- a/demos/supabase-todolist-drift/macos/Podfile.lock +++ /dev/null @@ -1,83 +0,0 @@ -PODS: - - app_links (1.0.0): - - FlutterMacOS - - FlutterMacOS (1.0.0) - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - - powersync-sqlite-core (0.4.2) - - powersync_flutter_libs (0.0.1): - - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): - - sqlite3/common - - sqlite3/fts5 (3.49.2): - - sqlite3/common - - sqlite3/math (3.49.2): - - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): - - sqlite3/common - - sqlite3/rtree (3.49.2): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.49.1) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - url_launcher_macos (0.0.1): - - FlutterMacOS - -DEPENDENCIES: - - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - powersync_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos`) - - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - -SPEC REPOS: - trunk: - - powersync-sqlite-core - - sqlite3 - -EXTERNAL SOURCES: - app_links: - :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos - FlutterMacOS: - :path: Flutter/ephemeral - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - powersync_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos - shared_preferences_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin - sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos - -SPEC CHECKSUMS: - app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 - url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 - -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 - -COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.pbxproj b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.pbxproj index f1568b4e..5c87fe22 100644 --- a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.pbxproj @@ -27,8 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 4C45D24842D87F346303A231 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 405D516BBE4D2F56B5B3E250 /* Pods_RunnerTests.framework */; }; - E4DB7943952B9B89759CECF4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D3ADAAD4C9C2AA56D71B37 /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,9 +61,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 04376F92F675FFC32D91401C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 0EF7B02F8BCD6AB8E685141E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 149DFD888D02B6A66AC60434 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -81,12 +77,8 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 405D516BBE4D2F56B5B3E250 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 42B0BA50A36508E850C0AA8E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 53D3ADAAD4C9C2AA56D71B37 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 805FB9B52EBDEA42A8809DDA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 92A34CE639216754BD3BD6F0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -95,7 +87,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4C45D24842D87F346303A231 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,7 +94,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E4DB7943952B9B89759CECF4 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -136,8 +127,6 @@ 33CEB47122A05771004F2AC0 /* Flutter */, 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - 78DDA3D3919F4F3973A93CA1 /* Pods */, ); sourceTree = ""; }; @@ -164,6 +153,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -185,29 +175,6 @@ path = Runner; sourceTree = ""; }; - 78DDA3D3919F4F3973A93CA1 /* Pods */ = { - isa = PBXGroup; - children = ( - 42B0BA50A36508E850C0AA8E /* Pods-Runner.debug.xcconfig */, - 805FB9B52EBDEA42A8809DDA /* Pods-Runner.release.xcconfig */, - 04376F92F675FFC32D91401C /* Pods-Runner.profile.xcconfig */, - 0EF7B02F8BCD6AB8E685141E /* Pods-RunnerTests.debug.xcconfig */, - 149DFD888D02B6A66AC60434 /* Pods-RunnerTests.release.xcconfig */, - 92A34CE639216754BD3BD6F0 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 53D3ADAAD4C9C2AA56D71B37 /* Pods_Runner.framework */, - 405D516BBE4D2F56B5B3E250 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -215,7 +182,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - D4E4B54F7D911A322DFD8C50 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -234,13 +200,11 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 9D63958A36C1A2768E03404F /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 853434743D1185FBE69475FF /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,6 +212,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* supabase_todolist_drift.app */; productType = "com.apple.product-type.application"; @@ -292,6 +259,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -361,67 +331,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 853434743D1185FBE69475FF /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 9D63958A36C1A2768E03404F /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - D4E4B54F7D911A322DFD8C50 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -473,7 +382,6 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0EF7B02F8BCD6AB8E685141E /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -488,7 +396,6 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 149DFD888D02B6A66AC60434 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -503,7 +410,6 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 92A34CE639216754BD3BD6F0 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -557,7 +463,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +545,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +595,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -796,6 +702,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..759d5a05 --- /dev/null +++ b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,22 @@ +{ + "pins" : [ + { + "identity" : "csqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simolus3/CSQLite.git", + "state" : { + "revision" : "a8d28afef08ad8faa4ee9ef7845f61c2e8ac5810" + } + }, + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" + } + } + ], + "version" : 2 +} diff --git a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 0b9a896d..6b3005f0 100644 --- a/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/demos/supabase-todolist-drift/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + =3.7.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.27.0" diff --git a/demos/supabase-todolist-drift/pubspec.yaml b/demos/supabase-todolist-drift/pubspec.yaml index 3773b612..051931b2 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.18+11 - powersync: ^1.15.0 + powersync_attachments_helper: ^0.6.20 + powersync: ^1.16.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 @@ -18,7 +18,7 @@ dependencies: camera: ^0.10.5+7 image: ^4.1.3 universal_io: ^2.2.2 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 drift: ^2.20.2 drift_sqlite_async: ^0.2.0 riverpod_annotation: ^2.6.1 @@ -41,3 +41,5 @@ dev_dependencies: flutter: uses-material-design: true + config: + enable-swift-package-manager: true diff --git a/demos/supabase-todolist-optional-sync/ios/Podfile b/demos/supabase-todolist-optional-sync/ios/Podfile index e9f73048..2c1e086a 100644 --- a/demos/supabase-todolist-optional-sync/ios/Podfile +++ b/demos/supabase-todolist-optional-sync/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-todolist-optional-sync/ios/Podfile.lock b/demos/supabase-todolist-optional-sync/ios/Podfile.lock index aac7e03f..146ca38d 100644 --- a/demos/supabase-todolist-optional-sync/ios/Podfile.lock +++ b/demos/supabase-todolist-optional-sync/ios/Podfile.lock @@ -7,10 +7,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -75,15 +75,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: f7b3cb7384a2d5da4b22b090e1f632de7f377987 +PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist-optional-sync/macos/Podfile b/demos/supabase-todolist-optional-sync/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-todolist-optional-sync/macos/Podfile +++ b/demos/supabase-todolist-optional-sync/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-todolist-optional-sync/macos/Podfile.lock b/demos/supabase-todolist-optional-sync/macos/Podfile.lock index f32a7411..6983b2da 100644 --- a/demos/supabase-todolist-optional-sync/macos/Podfile.lock +++ b/demos/supabase-todolist-optional-sync/macos/Podfile.lock @@ -5,10 +5,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -69,15 +69,15 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist-optional-sync/pubspec.lock b/demos/supabase-todolist-optional-sync/pubspec.lock index c7877841..26909e2d 100644 --- a/demos/supabase-todolist-optional-sync/pubspec.lock +++ b/demos/supabase-todolist-optional-sync/pubspec.lock @@ -462,21 +462,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" pub_semver: dependency: transitive description: diff --git a/demos/supabase-todolist-optional-sync/pubspec.yaml b/demos/supabase-todolist-optional-sync/pubspec.yaml index 57ddebec..477f3d02 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.15.0 + powersync: ^1.16.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 @@ -18,7 +18,7 @@ dependencies: camera: ^0.10.5+7 image: ^4.1.3 universal_io: ^2.2.2 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 dev_dependencies: flutter_test: diff --git a/demos/supabase-todolist/.metadata b/demos/supabase-todolist/.metadata new file mode 100644 index 00000000..6a623a4e --- /dev/null +++ b/demos/supabase-todolist/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: android + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: ios + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: linux + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: macos + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: web + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: windows + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/demos/supabase-todolist/android/.gitignore b/demos/supabase-todolist/android/.gitignore index 6f568019..be3943c9 100644 --- a/demos/supabase-todolist/android/.gitignore +++ b/demos/supabase-todolist/android/.gitignore @@ -5,9 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/demos/supabase-todolist/android/app/build.gradle b/demos/supabase-todolist/android/app/build.gradle deleted file mode 100644 index 9daa778b..00000000 --- a/demos/supabase-todolist/android/app/build.gradle +++ /dev/null @@ -1,70 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion flutter.compileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - applicationId "co.powersync.demotodolist" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 24 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig signingConfigs.debug - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/demos/supabase-todolist/android/app/build.gradle.kts b/demos/supabase-todolist/android/app/build.gradle.kts new file mode 100644 index 00000000..2fc63bb4 --- /dev/null +++ b/demos/supabase-todolist/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.powersync.demo.flutter.supabase_todolist.powersync_flutter_demo" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.powersync.demo.flutter.supabase_todolist.powersync_flutter_demo" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/demos/supabase-todolist/android/app/src/debug/AndroidManifest.xml b/demos/supabase-todolist/android/app/src/debug/AndroidManifest.xml index f19dd7d6..399f6981 100644 --- a/demos/supabase-todolist/android/app/src/debug/AndroidManifest.xml +++ b/demos/supabase-todolist/android/app/src/debug/AndroidManifest.xml @@ -1,4 +1,7 @@ - + + diff --git a/demos/supabase-todolist/android/app/src/main/AndroidManifest.xml b/demos/supabase-todolist/android/app/src/main/AndroidManifest.xml index 55e175c4..e85f03e3 100644 --- a/demos/supabase-todolist/android/app/src/main/AndroidManifest.xml +++ b/demos/supabase-todolist/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,13 @@ - - + + + + + + + + diff --git a/demos/supabase-todolist/android/app/src/main/kotlin/co/powersync/demotodolist/MainActivity.kt b/demos/supabase-todolist/android/app/src/main/kotlin/co/powersync/demotodolist/MainActivity.kt deleted file mode 100644 index 88fda765..00000000 --- a/demos/supabase-todolist/android/app/src/main/kotlin/co/powersync/demotodolist/MainActivity.kt +++ /dev/null @@ -1,6 +0,0 @@ -package co.powersync.demotodolist - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() { -} diff --git a/demos/supabase-todolist/android/app/src/main/kotlin/com/powersync/demo/flutter/supabase_todolist/powersync_flutter_demo/MainActivity.kt b/demos/supabase-todolist/android/app/src/main/kotlin/com/powersync/demo/flutter/supabase_todolist/powersync_flutter_demo/MainActivity.kt new file mode 100644 index 00000000..70c9dd12 --- /dev/null +++ b/demos/supabase-todolist/android/app/src/main/kotlin/com/powersync/demo/flutter/supabase_todolist/powersync_flutter_demo/MainActivity.kt @@ -0,0 +1,5 @@ +package com.powersync.demo.flutter.supabase_todolist.powersync_flutter_demo + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/demos/supabase-todolist/android/app/src/profile/AndroidManifest.xml b/demos/supabase-todolist/android/app/src/profile/AndroidManifest.xml index f19dd7d6..399f6981 100644 --- a/demos/supabase-todolist/android/app/src/profile/AndroidManifest.xml +++ b/demos/supabase-todolist/android/app/src/profile/AndroidManifest.xml @@ -1,4 +1,7 @@ - + + diff --git a/demos/supabase-todolist/android/build.gradle b/demos/supabase-todolist/android/build.gradle deleted file mode 100644 index 713d7f6e..00000000 --- a/demos/supabase-todolist/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.7.10' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/demos/supabase-todolist/android/build.gradle.kts b/demos/supabase-todolist/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/demos/supabase-todolist/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/demos/supabase-todolist/android/gradle.properties b/demos/supabase-todolist/android/gradle.properties index 94adc3a3..f018a618 100644 --- a/demos/supabase-todolist/android/gradle.properties +++ b/demos/supabase-todolist/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true diff --git a/demos/supabase-todolist/android/gradle/wrapper/gradle-wrapper.properties b/demos/supabase-todolist/android/gradle/wrapper/gradle-wrapper.properties index 3c472b99..ac3b4792 100644 --- a/demos/supabase-todolist/android/gradle/wrapper/gradle-wrapper.properties +++ b/demos/supabase-todolist/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/demos/supabase-todolist/android/settings.gradle b/demos/supabase-todolist/android/settings.gradle deleted file mode 100644 index 44e62bcf..00000000 --- a/demos/supabase-todolist/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/demos/supabase-todolist/android/settings.gradle.kts b/demos/supabase-todolist/android/settings.gradle.kts new file mode 100644 index 00000000..ab39a10a --- /dev/null +++ b/demos/supabase-todolist/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/demos/supabase-todolist/ios/Flutter/AppFrameworkInfo.plist b/demos/supabase-todolist/ios/Flutter/AppFrameworkInfo.plist index 7c569640..1dc6cf76 100644 --- a/demos/supabase-todolist/ios/Flutter/AppFrameworkInfo.plist +++ b/demos/supabase-todolist/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/demos/supabase-todolist/ios/Podfile b/demos/supabase-todolist/ios/Podfile index e9f73048..2c1e086a 100644 --- a/demos/supabase-todolist/ios/Podfile +++ b/demos/supabase-todolist/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-todolist/ios/Podfile.lock b/demos/supabase-todolist/ios/Podfile.lock index aac7e03f..73dadf31 100644 --- a/demos/supabase-todolist/ios/Podfile.lock +++ b/demos/supabase-todolist/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - app_links (0.0.2): + - app_links (6.4.1): - Flutter - camera_avfoundation (0.0.1): - Flutter @@ -7,35 +7,39 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.6) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) + - FlutterMacOS + - powersync-sqlite-core (~> 0.4.6) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): - sqlite3/common - - sqlite3/fts5 (3.49.2): + - sqlite3/math (3.50.4): - sqlite3/common - - sqlite3/math (3.49.2): + - sqlite3/perf-threadsafe (3.50.4): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): + - sqlite3/rtree (3.50.4): - sqlite3/common - - sqlite3/rtree (3.49.2): + - sqlite3/session (3.50.4): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.4) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree + - sqlite3/session - url_launcher_ios (0.0.1): - Flutter @@ -44,7 +48,7 @@ DEPENDENCIES: - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - Flutter (from `Flutter`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - - powersync_flutter_libs (from `.symlinks/plugins/powersync_flutter_libs/ios`) + - powersync_flutter_libs (from `.symlinks/plugins/powersync_flutter_libs/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -64,7 +68,7 @@ EXTERNAL SOURCES: path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" powersync_flutter_libs: - :path: ".symlinks/plugins/powersync_flutter_libs/ios" + :path: ".symlinks/plugins/powersync_flutter_libs/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqlite3_flutter_libs: @@ -73,17 +77,17 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 + powersync-sqlite-core: 42c4a42a692b3b770a5488778789430d67a39b49 + powersync_flutter_libs: 19fc6b96ff8155ffea72a08990f6c9f2e712b8a6 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: f7b3cb7384a2d5da4b22b090e1f632de7f377987 +PODFILE CHECKSUM: 2c1730c97ea13f1ea48b32e9c79de785b4f2f02f COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist/ios/Runner.xcodeproj/project.pbxproj b/demos/supabase-todolist/ios/Runner.xcodeproj/project.pbxproj index e32e9fa6..f6a8fba5 100644 --- a/demos/supabase-todolist/ios/Runner.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/ios/Runner.xcodeproj/project.pbxproj @@ -342,7 +342,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -419,7 +419,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -468,7 +468,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/demos/supabase-todolist/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/demos/supabase-todolist/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c53e2b31..9c12df59 100644 --- a/demos/supabase-todolist/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/demos/supabase-todolist/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> localAttachmentStorage() async { + final appDocDir = await getApplicationDocumentsDirectory(); + return IOLocalStorage(appDocDir); +} diff --git a/demos/supabase-todolist/lib/attachments/local_storage_unsupported.dart b/demos/supabase-todolist/lib/attachments/local_storage_unsupported.dart new file mode 100644 index 00000000..811fa7d3 --- /dev/null +++ b/demos/supabase-todolist/lib/attachments/local_storage_unsupported.dart @@ -0,0 +1,7 @@ +import 'package:powersync_core/attachments/attachments.dart'; + +Future localAttachmentStorage() async { + // This file is imported on the web, where we don't currently have a + // persistent local storage implementation. + return LocalStorage.inMemory(); +} diff --git a/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart b/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart index 38838dd7..89660808 100644 --- a/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart +++ b/demos/supabase-todolist/lib/attachments/photo_capture_widget.dart @@ -1,11 +1,9 @@ import 'dart:async'; - +import 'dart:io'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -import 'package:powersync/powersync.dart' as powersync; +import 'package:logging/logging.dart'; import 'package:powersync_flutter_demo/attachments/queue.dart'; -import 'package:powersync_flutter_demo/models/todo_item.dart'; -import 'package:powersync_flutter_demo/powersync.dart'; class TakePhotoWidget extends StatefulWidget { final String todoId; @@ -23,6 +21,7 @@ class TakePhotoWidget extends StatefulWidget { class _TakePhotoWidgetState extends State { late CameraController _cameraController; late Future _initializeControllerFuture; + final log = Logger('TakePhotoWidget'); @override void initState() { @@ -37,7 +36,6 @@ class _TakePhotoWidgetState extends State { } @override - // Dispose of the camera controller when the widget is disposed void dispose() { _cameraController.dispose(); super.dispose(); @@ -45,25 +43,26 @@ class _TakePhotoWidgetState extends State { Future _takePhoto(context) async { try { - // Ensure the camera is initialized before taking a photo + log.info('Taking photo for todo: ${widget.todoId}'); await _initializeControllerFuture; - final XFile photo = await _cameraController.takePicture(); - // copy photo to new directory with ID as name - String photoId = powersync.uuid.v4(); - String storageDirectory = await attachmentQueue.getStorageDirectory(); - await attachmentQueue.localStorage - .copyFile(photo.path, '$storageDirectory/$photoId.jpg'); - int photoSize = await photo.length(); + // Read the photo data as bytes + final photoFile = File(photo.path); + if (!await photoFile.exists()) { + log.warning('Photo file does not exist: ${photo.path}'); + return; + } + + final photoData = photoFile.openRead(); - TodoItem.addPhoto(photoId, widget.todoId); - attachmentQueue.saveFile(photoId, photoSize); + // Save the photo attachment with the byte data + final attachment = await savePhotoAttachment(photoData, widget.todoId); + + log.info('Photo attachment saved with ID: ${attachment.id}'); } catch (e) { - log.info('Error taking photo: $e'); + log.severe('Error taking photo: $e'); } - - // After taking the photo, navigate back to the previous screen Navigator.pop(context); } diff --git a/demos/supabase-todolist/lib/attachments/photo_widget.dart b/demos/supabase-todolist/lib/attachments/photo_widget.dart index f034ef5b..f41bc0b0 100644 --- a/demos/supabase-todolist/lib/attachments/photo_widget.dart +++ b/demos/supabase-todolist/lib/attachments/photo_widget.dart @@ -1,12 +1,14 @@ import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; import 'package:flutter/material.dart'; -import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; +import 'package:powersync_core/attachments/attachments.dart'; import 'package:powersync_flutter_demo/attachments/camera_helpers.dart'; import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart'; -import 'package:powersync_flutter_demo/attachments/queue.dart'; import '../models/todo_item.dart'; +import '../powersync.dart'; class PhotoWidget extends StatefulWidget { final TodoItem todo; @@ -37,11 +39,12 @@ class _PhotoWidgetState extends State { if (photoId == null) { return _ResolvedPhotoState(photoPath: null, fileExists: false); } - photoPath = await attachmentQueue.getLocalUri('$photoId.jpg'); + final appDocDir = await getApplicationDocumentsDirectory(); + photoPath = p.join(appDocDir.path, '$photoId.jpg'); bool fileExists = await File(photoPath).exists(); - final row = await attachmentQueue.db + final row = await db .getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]); if (row != null) { @@ -98,7 +101,7 @@ class _PhotoWidgetState extends State { String? filePath = data.photoPath; bool fileIsDownloading = !data.fileExists; bool fileArchived = - data.attachment?.state == AttachmentState.archived.index; + data.attachment?.state == AttachmentState.archived; if (fileArchived) { return Column( diff --git a/demos/supabase-todolist/lib/attachments/queue.dart b/demos/supabase-todolist/lib/attachments/queue.dart index 2a8dd9ca..8f036c85 100644 --- a/demos/supabase-todolist/lib/attachments/queue.dart +++ b/demos/supabase-todolist/lib/attachments/queue.dart @@ -1,90 +1,55 @@ import 'dart:async'; +import 'package:logging/logging.dart'; import 'package:powersync/powersync.dart'; -import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; -import 'package:powersync_flutter_demo/app_config.dart'; +import 'package:powersync_core/attachments/attachments.dart'; + import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart'; -import 'package:powersync_flutter_demo/models/schema.dart'; +import 'local_storage_unsupported.dart' + if (dart.library.io) 'local_storage_native.dart'; -/// Global reference to the queue -late final PhotoAttachmentQueue attachmentQueue; +late AttachmentQueue attachmentQueue; final remoteStorage = SupabaseStorageAdapter(); - -/// Function to handle errors when downloading attachments -/// Return false if you want to archive the attachment -Future onDownloadError(Attachment attachment, Object exception) async { - if (exception.toString().contains('Object not found')) { - return false; - } - return true; -} - -class PhotoAttachmentQueue extends AbstractAttachmentQueue { - PhotoAttachmentQueue(db, remoteStorage) - : super( - db: db, - remoteStorage: remoteStorage, - onDownloadError: onDownloadError); - - @override - init() async { - if (AppConfig.supabaseStorageBucket.isEmpty) { - log.info( - 'No Supabase bucket configured, skip setting up PhotoAttachmentQueue watches'); - return; - } - - await super.init(); - } - - @override - Future saveFile(String fileId, int size, - {mediaType = 'image/jpeg'}) async { - String filename = '$fileId.jpg'; - - Attachment photoAttachment = Attachment( - id: fileId, - filename: filename, - state: AttachmentState.queuedUpload.index, - mediaType: mediaType, - localUri: getLocalFilePathSuffix(filename), - size: size, - ); - - return attachmentsService.saveAttachment(photoAttachment); - } - - @override - Future deleteFile(String fileId) async { - String filename = '$fileId.jpg'; - - Attachment photoAttachment = Attachment( - id: fileId, - filename: filename, - state: AttachmentState.queuedDelete.index); - - return attachmentsService.saveAttachment(photoAttachment); - } - - @override - StreamSubscription watchIds({String fileExtension = 'jpg'}) { - log.info('Watching photos in $todosTable...'); - return db.watch(''' - SELECT photo_id FROM $todosTable - WHERE photo_id IS NOT NULL - ''').map((results) { - return results.map((row) => row['photo_id'] as String).toList(); - }).listen((ids) async { - List idsInQueue = await attachmentsService.getAttachmentIds(); - List relevantIds = - ids.where((element) => !idsInQueue.contains(element)).toList(); - syncingService.processIds(relevantIds, fileExtension); - }); - } +final logger = Logger('AttachmentQueue'); + +Future initializeAttachmentQueue(PowerSyncDatabase db) async { + attachmentQueue = AttachmentQueue( + db: db, + remoteStorage: remoteStorage, + logger: logger, + localStorage: await localAttachmentStorage(), + watchAttachments: () => db.watch(''' + SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL + ''').map( + (results) => [ + for (final row in results) + WatchedAttachmentItem( + id: row['id'] as String, + fileExtension: 'jpg', + ) + ], + ), + ); + + await attachmentQueue.startSync(); } -initializeAttachmentQueue(PowerSyncDatabase db) async { - attachmentQueue = PhotoAttachmentQueue(db, remoteStorage); - await attachmentQueue.init(); +Future savePhotoAttachment( + Stream> photoData, String todoId, + {String mediaType = 'image/jpeg'}) async { + // Save the file using the AttachmentQueue API + return await attachmentQueue.saveFile( + data: photoData, + mediaType: mediaType, + fileExtension: 'jpg', + metaData: 'Photo attachment for todo: $todoId', + updateHook: (context, attachment) async { + // Update the todo item to reference this attachment + await context.execute( + 'UPDATE todos SET photo_id = ? WHERE id = ?', + [attachment.id, todoId], + ); + }, + ); } diff --git a/demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart b/demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart index 596c5da5..5b711b83 100644 --- a/demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart +++ b/demos/supabase-todolist/lib/attachments/remote_storage_adapter.dart @@ -1,49 +1,96 @@ import 'dart:io'; import 'dart:typed_data'; -import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; + +import 'package:powersync_core/attachments/attachments.dart'; import 'package:powersync_flutter_demo/app_config.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:image/image.dart' as img; +import 'package:logging/logging.dart'; + +class SupabaseStorageAdapter implements RemoteStorage { + static final _log = Logger('SupabaseStorageAdapter'); -class SupabaseStorageAdapter implements AbstractRemoteStorageAdapter { @override - Future uploadFile(String filename, File file, - {String mediaType = 'text/plain'}) async { + Future uploadFile( + Stream> fileData, Attachment attachment) async { _checkSupabaseBucketIsConfigured(); + // Check if attachment size is specified (required for buffer allocation) + final byteSize = attachment.size; + if (byteSize == null) { + throw Exception('Cannot upload a file with no byte size specified'); + } + + _log.info('uploadFile: ${attachment.filename} (size: $byteSize bytes)'); + + // Collect all stream data into a single Uint8List buffer + final buffer = Uint8List(byteSize); + var position = 0; + + await for (final chunk in fileData) { + if (position + chunk.length > byteSize) { + throw Exception('File data exceeds specified size'); + } + buffer.setRange(position, position + chunk.length, chunk); + position += chunk.length; + } + + if (position != byteSize) { + throw Exception( + 'File data size ($position) does not match specified size ($byteSize)'); + } + + // Create a temporary file from the buffer for upload + final tempFile = + File('${Directory.systemTemp.path}/${attachment.filename}'); try { + await tempFile.writeAsBytes(buffer); + await Supabase.instance.client.storage .from(AppConfig.supabaseStorageBucket) - .upload(filename, file, - fileOptions: FileOptions(contentType: mediaType)); + .upload(attachment.filename, tempFile, + fileOptions: FileOptions( + contentType: + attachment.mediaType ?? 'application/octet-stream')); + + _log.info('Successfully uploaded ${attachment.filename}'); } catch (error) { + _log.severe('Error uploading ${attachment.filename}', error); throw Exception(error); + } finally { + if (await tempFile.exists()) { + await tempFile.delete(); + } } } @override - Future downloadFile(String filePath) async { + Future>> downloadFile(Attachment attachment) async { _checkSupabaseBucketIsConfigured(); try { + _log.info('downloadFile: ${attachment.filename}'); + Uint8List fileBlob = await Supabase.instance.client.storage .from(AppConfig.supabaseStorageBucket) - .download(filePath); - final image = img.decodeImage(fileBlob); - Uint8List blob = img.JpegEncoder().encode(image!); - return blob; + .download(attachment.filename); + + _log.info( + 'Successfully downloaded ${attachment.filename} (${fileBlob.length} bytes)'); + + // Return the raw file data as a stream + return Stream.value(fileBlob); } catch (error) { + _log.severe('Error downloading ${attachment.filename}', error); throw Exception(error); } } @override - Future deleteFile(String filename) async { + Future deleteFile(Attachment attachment) async { _checkSupabaseBucketIsConfigured(); - try { await Supabase.instance.client.storage .from(AppConfig.supabaseStorageBucket) - .remove([filename]); + .remove([attachment.filename]); } catch (error) { throw Exception(error); } diff --git a/demos/supabase-todolist/lib/models/schema.dart b/demos/supabase-todolist/lib/models/schema.dart index 89b69b0c..5a6a261b 100644 --- a/demos/supabase-todolist/lib/models/schema.dart +++ b/demos/supabase-todolist/lib/models/schema.dart @@ -1,9 +1,9 @@ import 'package:powersync/powersync.dart'; -import 'package:powersync_attachments_helper/powersync_attachments_helper.dart'; +import 'package:powersync_core/attachments/attachments.dart'; const todosTable = 'todos'; -Schema schema = Schema(([ +Schema schema = Schema([ const Table(todosTable, [ Column.text('list_id'), Column.text('photo_id'), @@ -22,6 +22,5 @@ Schema schema = Schema(([ Column.text('name'), Column.text('owner_id') ]), - AttachmentsQueueTable( - attachmentsQueueTableName: defaultAttachmentsQueueTableName) -])); + AttachmentsQueueTable() +]); diff --git a/demos/supabase-todolist/lib/widgets/guard_by_sync.dart b/demos/supabase-todolist/lib/widgets/guard_by_sync.dart index d55ed4e3..6d4d12b9 100644 --- a/demos/supabase-todolist/lib/widgets/guard_by_sync.dart +++ b/demos/supabase-todolist/lib/widgets/guard_by_sync.dart @@ -7,9 +7,9 @@ import 'package:powersync_flutter_demo/powersync.dart'; class GuardBySync extends StatelessWidget { final Widget child; - /// When set, wait only for a complete sync within the [BucketPriority] + /// When set, wait only for a complete sync within the [StreamPriority] /// instead of a full sync. - final BucketPriority? priority; + final StreamPriority? priority; const GuardBySync({ super.key, diff --git a/demos/supabase-todolist/lib/widgets/list_item_sync_stream.dart b/demos/supabase-todolist/lib/widgets/list_item_sync_stream.dart new file mode 100644 index 00000000..e400f8aa --- /dev/null +++ b/demos/supabase-todolist/lib/widgets/list_item_sync_stream.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import '../powersync.dart'; +import './todo_list_page.dart'; +import '../models/todo_list.dart'; + +/// A variant of the `ListItem` that only shows a summary of completed and +/// pending items when the respective list has an active sync stream. +class SyncStreamsAwareListItem extends StatelessWidget { + SyncStreamsAwareListItem({ + required this.list, + }) : super(key: ObjectKey(list)); + + final TodoList list; + + Future delete() async { + // Server will take care of deleting related todos + await list.delete(); + } + + @override + Widget build(BuildContext context) { + viewList() { + var navigator = Navigator.of(context); + + navigator.push( + MaterialPageRoute(builder: (context) => TodoListPage(list: list))); + } + + return StreamBuilder( + stream: db.statusStream, + initialData: db.currentStatus, + builder: (context, asyncSnapshot) { + final status = asyncSnapshot.requireData; + final stream = + status.forStream(db.syncStream('todos', {'list': list.id})); + + String subtext; + if (stream == null || !stream.subscription.active) { + subtext = 'Items not loaded - click to fetch.'; + } else { + subtext = + '${list.pendingCount} pending, ${list.completedCount} completed'; + } + + return Card( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: viewList, + leading: const Icon(Icons.list), + title: Text(list.name), + subtitle: Text(subtext), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + iconSize: 30, + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + tooltip: 'Delete List', + alignment: Alignment.centerRight, + onPressed: delete, + ), + const SizedBox(width: 8), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/demos/supabase-todolist/lib/widgets/lists_page.dart b/demos/supabase-todolist/lib/widgets/lists_page.dart index c41aabbe..564e03ee 100644 --- a/demos/supabase-todolist/lib/widgets/lists_page.dart +++ b/demos/supabase-todolist/lib/widgets/lists_page.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:powersync/powersync.dart'; +import '../app_config.dart'; import './list_item.dart'; import './list_item_dialog.dart'; import '../main.dart'; import '../models/todo_list.dart'; import 'guard_by_sync.dart'; +import 'list_item_sync_stream.dart'; void _showAddDialog(BuildContext context) async { return showDialog( @@ -55,7 +57,9 @@ class ListsWidget extends StatelessWidget { return ListView( padding: const EdgeInsets.symmetric(vertical: 8.0), children: todoLists.map((list) { - return ListItemWidget(list: list); + return AppConfig.hasSyncStreams + ? SyncStreamsAwareListItem(list: list) + : ListItemWidget(list: list); }).toList(), ); } else { @@ -66,5 +70,5 @@ class ListsWidget extends StatelessWidget { ); } - static final _listsPriority = BucketPriority(1); + static final _listsPriority = StreamPriority(1); } diff --git a/demos/supabase-todolist/lib/widgets/todo_item_widget.dart b/demos/supabase-todolist/lib/widgets/todo_item_widget.dart index a59812ed..700a869a 100644 --- a/demos/supabase-todolist/lib/widgets/todo_item_widget.dart +++ b/demos/supabase-todolist/lib/widgets/todo_item_widget.dart @@ -23,7 +23,13 @@ class TodoItemWidget extends StatelessWidget { Future deleteTodo(TodoItem todo) async { if (todo.photoId != null) { - attachmentQueue.deleteFile(todo.photoId!); + await attachmentQueue.deleteFile( + attachmentId: todo.photoId!, + updateHook: (context, attachment) async { + await context.execute( + "UPDATE todos SET photo_id = NULL WHERE id = ?", [todo.id]); + }, + ); } await todo.delete(); } diff --git a/demos/supabase-todolist/lib/widgets/todo_list_page.dart b/demos/supabase-todolist/lib/widgets/todo_list_page.dart index 7e28238e..b1245321 100644 --- a/demos/supabase-todolist/lib/widgets/todo_list_page.dart +++ b/demos/supabase-todolist/lib/widgets/todo_list_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:powersync/powersync.dart'; +import '../app_config.dart'; import '../powersync.dart'; import './status_app_bar.dart'; import './todo_item_dialog.dart'; @@ -32,9 +34,12 @@ class TodoListPage extends StatelessWidget { ); return Scaffold( - appBar: StatusAppBar(title: Text(list.name)), - floatingActionButton: button, - body: TodoListWidget(list: list)); + appBar: StatusAppBar(title: Text(list.name)), + floatingActionButton: button, + body: AppConfig.hasSyncStreams + ? _SyncStreamTodoListWidget(list: list) + : TodoListWidget(list: list), + ); } } @@ -66,3 +71,84 @@ class TodoListWidget extends StatelessWidget { ); } } + +class _SyncStreamTodoListWidget extends StatefulWidget { + final TodoList list; + + const _SyncStreamTodoListWidget({required this.list}); + + @override + State<_SyncStreamTodoListWidget> createState() => _SyncStreamTodosState(); +} + +class _SyncStreamTodosState extends State<_SyncStreamTodoListWidget> { + SyncStreamSubscription? _listSubscription; + + void _subscribe(String listId) { + db + .syncStream('todos', {'list': listId}) + .subscribe(ttl: const Duration(hours: 1)) + .then((sub) { + if (mounted && widget.list.id == listId) { + setState(() { + _listSubscription = sub; + }); + } else { + sub.unsubscribe(); + } + }); + } + + @override + void initState() { + super.initState(); + _subscribe(widget.list.id); + } + + @override + void didUpdateWidget(covariant _SyncStreamTodoListWidget oldWidget) { + super.didUpdateWidget(oldWidget); + _subscribe(widget.list.id); + } + + @override + void dispose() { + super.dispose(); + _listSubscription?.unsubscribe(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: db.statusStream, + initialData: db.currentStatus, + builder: (context, snapshot) { + final hasSynced = switch (_listSubscription) { + null => null, + final sub => snapshot.requireData.forStream(sub), + } + ?.subscription + .hasSynced ?? + false; + + if (!hasSynced) { + return const Center(child: CircularProgressIndicator()); + } else { + return StreamBuilder( + stream: widget.list.watchItems(), + builder: (context, snapshot) { + final items = snapshot.data ?? const []; + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8.0), + children: items.map((todo) { + return TodoItemWidget(todo: todo); + }).toList(), + ); + }, + ); + } + }, + ); + } +} diff --git a/demos/supabase-todolist/linux/runner/CMakeLists.txt b/demos/supabase-todolist/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/demos/supabase-todolist/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/demos/supabase-todolist/linux/runner/main.cc b/demos/supabase-todolist/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/demos/supabase-todolist/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/demos/supabase-todolist/linux/runner/my_application.cc b/demos/supabase-todolist/linux/runner/my_application.cc new file mode 100644 index 00000000..dde6b0ac --- /dev/null +++ b/demos/supabase-todolist/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "powersync_flutter_demo"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "powersync_flutter_demo"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/demos/supabase-todolist/linux/runner/my_application.h b/demos/supabase-todolist/linux/runner/my_application.h new file mode 100644 index 00000000..72271d5e --- /dev/null +++ b/demos/supabase-todolist/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/demos/supabase-todolist/macos/Podfile b/demos/supabase-todolist/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-todolist/macos/Podfile +++ b/demos/supabase-todolist/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-todolist/macos/Podfile.lock b/demos/supabase-todolist/macos/Podfile.lock index f32a7411..51b6fdad 100644 --- a/demos/supabase-todolist/macos/Podfile.lock +++ b/demos/supabase-todolist/macos/Podfile.lock @@ -1,39 +1,43 @@ PODS: - - app_links (1.0.0): + - app_links (6.4.1): - FlutterMacOS - FlutterMacOS (1.0.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.6) - powersync_flutter_libs (0.0.1): + - Flutter - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.6) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.49.2): - - sqlite3/common (= 3.49.2) - - sqlite3/common (3.49.2) - - sqlite3/dbstatvtab (3.49.2): + - sqlite3 (3.50.4): + - sqlite3/common (= 3.50.4) + - sqlite3/common (3.50.4) + - sqlite3/dbstatvtab (3.50.4): + - sqlite3/common + - sqlite3/fts5 (3.50.4): - sqlite3/common - - sqlite3/fts5 (3.49.2): + - sqlite3/math (3.50.4): - sqlite3/common - - sqlite3/math (3.49.2): + - sqlite3/perf-threadsafe (3.50.4): - sqlite3/common - - sqlite3/perf-threadsafe (3.49.2): + - sqlite3/rtree (3.50.4): - sqlite3/common - - sqlite3/rtree (3.49.2): + - sqlite3/session (3.50.4): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.49.1) + - sqlite3 (~> 3.50.4) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/math - sqlite3/perf-threadsafe - sqlite3/rtree + - sqlite3/session - url_launcher_macos (0.0.1): - FlutterMacOS @@ -41,7 +45,7 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - powersync_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos`) + - powersync_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -59,7 +63,7 @@ EXTERNAL SOURCES: path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin powersync_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/macos + :path: Flutter/ephemeral/.symlinks/plugins/powersync_flutter_libs/darwin shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqlite3_flutter_libs: @@ -68,16 +72,16 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: - app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 42c4a42a692b3b770a5488778789430d67a39b49 + powersync_flutter_libs: 19fc6b96ff8155ffea72a08990f6c9f2e712b8a6 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 - sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 + sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b + sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-todolist/macos/Runner.xcodeproj/project.pbxproj b/demos/supabase-todolist/macos/Runner.xcodeproj/project.pbxproj index 5581abf6..8c3f956d 100644 --- a/demos/supabase-todolist/macos/Runner.xcodeproj/project.pbxproj +++ b/demos/supabase-todolist/macos/Runner.xcodeproj/project.pbxproj @@ -508,7 +508,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -587,7 +587,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -634,7 +634,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/demos/supabase-todolist/macos/RunnerTests/RunnerTests.swift b/demos/supabase-todolist/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/demos/supabase-todolist/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/demos/supabase-todolist/pubspec.lock b/demos/supabase-todolist/pubspec.lock index 7522f0ed..24fad347 100644 --- a/demos/supabase-todolist/pubspec.lock +++ b/demos/supabase-todolist/pubspec.lock @@ -1,14 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + url: "https://pub.dev" + source: hosted + version: "7.7.1" app_links: dependency: transitive description: name: app_links - sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba" + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "6.4.1" app_links_linux: dependency: transitive description: @@ -77,18 +93,18 @@ packages: dependency: transitive description: name: camera_android - sha256: "08808be7e26fc3c7426c81b3fa387564b8e9c22e6fe9cb5675ce3ab7017d8203" + sha256: "4db8a27da163130d913ab4360297549ead1c7f9a6a88e71c44e5f4d10081a3d4" url: "https://pub.dev" source: hosted - version: "0.10.10+3" + version: "0.10.10+6" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: ca36181194f429eef3b09de3c96280f2400693f9735025f90d1f4a27465fdd72 + sha256: "951ef122d01ebba68b7a54bfe294e8b25585635a90465c311b2f875ae72c412f" url: "https://pub.dev" source: hosted - version: "0.9.19" + version: "0.9.21+2" camera_platform_interface: dependency: transitive description: @@ -117,10 +133,18 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -137,6 +161,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" cross_file: dependency: transitive description: @@ -202,10 +242,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 url: "https://pub.dev" source: hosted - version: "2.0.28" + version: "2.0.30" flutter_test: dependency: "direct dev" description: flutter @@ -216,22 +256,38 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" functions_client: dependency: transitive description: name: functions_client - sha256: b410e4d609522357396cd84bb9a8f6e3a4561b5f7d3ce82267f6f1c2af42f16b + sha256: "38e5049d4ca5b3482c606d8bfe82183aa24c9650ef1fa0582ab5957a947b937f" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gotrue: dependency: transitive description: name: gotrue - sha256: "04a6efacffd42773ed96dc752f19bb20a1fbc383e81ba82659072b775cf62912" + sha256: "4ed944bfa31cca12e6d224ed07557ccf1bb604959de3c7d5282cd11314e7655b" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.14.0" gtk: dependency: transitive description: @@ -244,10 +300,18 @@ packages: dependency: transitive description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -264,6 +328,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -284,26 +364,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -360,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: "direct main" description: @@ -380,18 +476,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.18" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" path_provider_linux: dependency: transitive description: @@ -420,10 +516,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.1" platform: dependency: transitive description: @@ -440,14 +536,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" posix: dependency: transitive description: name: posix - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.0.3" postgrest: dependency: transitive description: @@ -462,28 +566,28 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.2" powersync_attachments_helper: - dependency: "direct main" + dependency: "direct overridden" description: path: "../../packages/powersync_attachments_helper" relative: true source: path - version: "0.6.18+7" + version: "0.6.19" powersync_core: - dependency: "direct overridden" + dependency: "direct main" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.2" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.11" pub_semver: dependency: transitive description: @@ -504,10 +608,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "3a0a99b5bd0fc3b35e8ee846d9a22fa2c2117f7ef1cb73d1e5f08f6c3d09c4e9" + sha256: "025b7e690e8dcf27844f37d140cca47da5ab31d6fe8d78347fb16763f0a4beb6" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.2" retry: dependency: transitive description: @@ -536,10 +640,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.12" shared_preferences_foundation: dependency: transitive description: @@ -580,11 +684,59 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -605,33 +757,34 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e" + sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 url: "https://pub.dev" source: hosted - version: "2.7.5" + version: "2.9.0" sqlite3_flutter_libs: dependency: transitive description: name: sqlite3_flutter_libs - sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14" + sha256: "2b03273e71867a8a4d030861fc21706200debe5c5858a4b9e58f4a1c129586a4" url: "https://pub.dev" source: hosted - version: "0.5.32" + version: "0.5.39" sqlite3_web: dependency: transitive description: name: sqlite3_web - sha256: "967e076442f7e1233bd7241ca61f3efe4c7fc168dac0f38411bdb3bdf471eb3c" + sha256: "0f6ebcb4992d1892ac5c8b5ecd22a458ab9c5eb6428b11ae5ecb5d63545844da" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.2" sqlite_async: dependency: "direct main" description: - path: "/Users/simon/src/sqlite_async.dart/packages/sqlite_async" - relative: false - source: path - version: "0.11.5" + name: sqlite_async + sha256: "6116bfc6aef6ce77730b478385ba4a58873df45721f6a9bc6ffabf39b6576e36" + url: "https://pub.dev" + source: hosted + version: "0.12.1" stack_trace: dependency: transitive description: @@ -644,10 +797,10 @@ packages: dependency: transitive description: name: storage_client - sha256: "09bac4d75eea58e8113ca928e6655a09cc8059e6d1b472ee801f01fde815bcfc" + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" stream_channel: dependency: transitive description: @@ -676,18 +829,18 @@ packages: dependency: transitive description: name: supabase - sha256: f00172f5f0b2148ea1c573f52862d50cacb6f353f579f741fa35e51704845958 + sha256: da2afea0f06b06fd0ebb23b916af05df2ef6d56dccae5c0112a2171e668bf9b2 url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.9.0" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: d88eccf9e46e57129725a08e72a3109b6f780921fdc27fe3d7669a11ae80906b + sha256: f7eefb065f00f947a8f2d0fbb4be3e687ed7165e80c798bc8c4f0d4295855c51 url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.10.0" term_glyph: dependency: transitive description: @@ -696,14 +849,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + url: "https://pub.dev" + source: hosted + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + url: "https://pub.dev" + source: hosted + version: "0.6.11" typed_data: dependency: transitive description: @@ -724,26 +893,26 @@ packages: dependency: transitive description: name: url_launcher - sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.2" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.18" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.3.4" url_launcher_linux: dependency: transitive description: @@ -756,10 +925,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_platform_interface: dependency: transitive description: @@ -796,18 +965,26 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://pub.dev" + source: hosted + version: "1.1.3" web: dependency: transitive description: @@ -832,6 +1009,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" xdg_directories: dependency: transitive description: @@ -844,10 +1029,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -865,5 +1050,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/demos/supabase-todolist/pubspec.yaml b/demos/supabase-todolist/pubspec.yaml index 725e4d64..2175e2b4 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.18+11 - powersync: ^1.15.0 + powersync: ^1.16.1 + powersync_core: ^1.6.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 @@ -19,13 +19,18 @@ dependencies: camera: ^0.10.5+7 image: ^4.1.3 universal_io: ^2.2.2 - sqlite_async: ^0.11.0 + sqlite_async: ^0.12.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.1 + test: ^1.25.15 flutter: uses-material-design: true + config: + # PowerSync supports SPM, but we want to disable it in this demo so that we can keep testing + # the CocoaPods parts of powersync_flutter_libs. + enable-swift-package-manager: false diff --git a/demos/supabase-trello/ios/Podfile b/demos/supabase-trello/ios/Podfile index 885313b0..a8c080ff 100644 --- a/demos/supabase-trello/ios/Podfile +++ b/demos/supabase-trello/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-trello/ios/Podfile.lock b/demos/supabase-trello/ios/Podfile.lock index 79306fa5..c47be6d3 100644 --- a/demos/supabase-trello/ios/Podfile.lock +++ b/demos/supabase-trello/ios/Podfile.lock @@ -41,13 +41,13 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - Flutter - - powersync-sqlite-core (~> 0.4.2) - - SDWebImage (5.21.1): - - SDWebImage/Core (= 5.21.1) - - SDWebImage/Core (5.21.1) + - powersync-sqlite-core (~> 0.4.5) + - SDWebImage (5.21.2): + - SDWebImage/Core (= 5.21.2) + - SDWebImage/Core (5.21.2) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -122,18 +122,18 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: 881187a07f70ecabaf802fce45b186485464d618 - SDWebImage: f29024626962457f3470184232766516dee8dfea + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 7684a62208907328906eb932f1fc8b3d8879974e + SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 9d8d1770d47a428fcb57a5d5f228a34e6d072ae7 +PODFILE CHECKSUM: 681bf989b1752c26661df140f63f5aad6922ddbb COCOAPODS: 1.16.2 diff --git a/demos/supabase-trello/lib/features/offlineboards/presentation/index.dart b/demos/supabase-trello/lib/features/offlineboards/presentation/index.dart index 39b8be28..ae59e8e7 100644 --- a/demos/supabase-trello/lib/features/offlineboards/presentation/index.dart +++ b/demos/supabase-trello/lib/features/offlineboards/presentation/index.dart @@ -78,7 +78,7 @@ class _OfflineBoardsState extends State with Service { onTap: () {}, trailing: Switch( value: brd[j].availableOffline ?? false, - activeColor: brandColor, + activeThumbColor: brandColor, onChanged: (bool value) { setState(() { brd[j].availableOffline = value; diff --git a/demos/supabase-trello/macos/Podfile b/demos/supabase-trello/macos/Podfile index c795730d..b52666a1 100644 --- a/demos/supabase-trello/macos/Podfile +++ b/demos/supabase-trello/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/demos/supabase-trello/macos/Podfile.lock b/demos/supabase-trello/macos/Podfile.lock index 0eed3cbb..93449db4 100644 --- a/demos/supabase-trello/macos/Podfile.lock +++ b/demos/supabase-trello/macos/Podfile.lock @@ -9,10 +9,10 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - powersync-sqlite-core (0.4.2) + - powersync-sqlite-core (0.4.5) - powersync_flutter_libs (0.0.1): - FlutterMacOS - - powersync-sqlite-core (~> 0.4.2) + - powersync-sqlite-core (~> 0.4.5) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -81,15 +81,15 @@ SPEC CHECKSUMS: app_links: afe860c55c7ef176cea7fb630a2b7d7736de591d file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - powersync-sqlite-core: a58efd88833861f0a8bb636c171bdf0ed55c9801 - powersync_flutter_libs: fa885a30ceb636655741eee2ff5282d0500fa96b + powersync-sqlite-core: 6f32860379009d2a37cadc9e9427a431bdbd83c8 + powersync_flutter_libs: 41d8a7b193abf15e46f95f0ec1229d86b6893171 shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sqlite3: 3c950dc86011117c307eb0b28c4a7bb449dce9f1 sqlite3_flutter_libs: f6acaa2172e6bb3e2e70c771661905080e8ebcf2 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 COCOAPODS: 1.16.2 diff --git a/demos/supabase-trello/pubspec.lock b/demos/supabase-trello/pubspec.lock index d101da0a..191e94d1 100644 --- a/demos/supabase-trello/pubspec.lock +++ b/demos/supabase-trello/pubspec.lock @@ -542,21 +542,21 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.13.0" + version: "1.15.0" powersync_core: dependency: "direct overridden" description: path: "../../packages/powersync_core" relative: true source: path - version: "1.3.0" + version: "1.5.0" powersync_flutter_libs: dependency: "direct overridden" description: path: "../../packages/powersync_flutter_libs" relative: true source: path - version: "0.4.8" + version: "0.4.10" provider: dependency: "direct main" description: diff --git a/demos/supabase-trello/pubspec.yaml b/demos/supabase-trello/pubspec.yaml index afaae23f..a609124e 100644 --- a/demos/supabase-trello/pubspec.yaml +++ b/demos/supabase-trello/pubspec.yaml @@ -36,8 +36,8 @@ dependencies: random_name_generator: ^1.5.0 flutter_dotenv: ^5.2.1 logging: ^1.3.0 - powersync: ^1.15.0 - sqlite_async: ^0.11.0 + powersync: ^1.16.1 + sqlite_async: ^0.12.0 path_provider: ^2.1.5 supabase_flutter: ^2.8.3 path: ^1.9.0 diff --git a/packages/powersync/CHANGELOG.md b/packages/powersync/CHANGELOG.md index b901657d..4f30cb92 100644 --- a/packages/powersync/CHANGELOG.md +++ b/packages/powersync/CHANGELOG.md @@ -1,3 +1,25 @@ +## 1.16.1 + + - Web: Fix decoding sync streams on status. + +## 1.16.0 + +- Add `getCrudTransactions()` returning a stream of completed transactions for uploads. +- Add experimental support for [sync streams](https://docs.powersync.com/usage/sync-streams). +- Add new attachments helper implementation in `package:powersync_core/attachments/attachments.dart`. +- Add SwiftPM support. + +## 1.15.2 + + - Fix excessive memory consumption during large sync. + +## 1.15.1 + + - Support latest versions of `package:sqlite3` and `package:sqlite_async`. + - Stream client: Improve `disconnect()` while a connection is being opened. + - Stream client: Support binary sync lines with Rust client and compatible PowerSync service versions. + - Sync client: Improve parsing error responses. + ## 1.15.0 - Update the PowerSync core extension to `0.4.2`. diff --git a/packages/powersync/pubspec.yaml b/packages/powersync/pubspec.yaml index 268cf642..26fffc25 100644 --- a/packages/powersync/pubspec.yaml +++ b/packages/powersync/pubspec.yaml @@ -1,5 +1,5 @@ name: powersync -version: 1.15.0 +version: 1.16.1 homepage: https://powersync.com repository: https://github.com/powersync-ja/powersync.dart description: PowerSync Flutter SDK. Sync Postgres, MongoDB or MySQL with SQLite in your Flutter app @@ -11,9 +11,9 @@ dependencies: flutter: sdk: flutter - sqlite3_flutter_libs: ^0.5.23 - powersync_core: ^1.5.0 - powersync_flutter_libs: ^0.4.10 + sqlite3_flutter_libs: ^0.5.39 + powersync_core: ^1.6.1 + powersync_flutter_libs: ^0.4.12 collection: ^1.17.0 dev_dependencies: diff --git a/packages/powersync_attachments_helper/CHANGELOG.md b/packages/powersync_attachments_helper/CHANGELOG.md index ab5b00e6..f80b8798 100644 --- a/packages/powersync_attachments_helper/CHANGELOG.md +++ b/packages/powersync_attachments_helper/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.6.20 + + - Add note about new attachment queue system in core package. + +## 0.6.19 + + - Remove direct dependency on `sqlite_async`. + ## 0.6.18+11 - Update a dependency to the latest release. diff --git a/packages/powersync_attachments_helper/README.md b/packages/powersync_attachments_helper/README.md index dbd65bef..c9b5f532 100644 --- a/packages/powersync_attachments_helper/README.md +++ b/packages/powersync_attachments_helper/README.md @@ -1,6 +1,20 @@ # PowerSync Attachments Helper for Dart/Flutter -[PowerSync Attachments Helper](https://pub.dev/packages/powersync_attachments_helper) is a package that assist in keeping files in sync with local and remote storage. +[PowerSync Attachments Helper](https://pub.dev/packages/powersync_attachments_helper) is a package that assists in keeping files in sync between local and remote storage. + +> [!WARNING] +> This package will eventually be replaced by a new attachments helper library in the core PowerSync package, available through: +> ```dart +> package:powersync_core/attachments/attachments.dart +> ``` +> +> The `powersync_core/attachments` library is in alpha and brings improved APIs and functionality that is more in line with our other SDKs, such as the ability to write your own local storage implementation. +> +> Check out the [docs here](https://pub.dev/documentation/powersync_core/latest/topics/attachments-topic.html) to get started. +> +> While the `powersync_attachments_helper` package will still get bug fixes if you need them, +> new features will only be developed on `powersync_core/attachments`. + ## Features @@ -83,5 +97,3 @@ initializeAttachmentQueue(PowerSyncDatabase db) async { await attachmentQueue.init(); } ``` - -See our [Supabase Flutter To-Do List example app](../../demos/supabase-todolist/README.md) for a concrete implementation of the above. diff --git a/packages/powersync_attachments_helper/pubspec.yaml b/packages/powersync_attachments_helper/pubspec.yaml index c920068a..971228f9 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.18+11 +version: 0.6.20 repository: https://github.com/powersync-ja/powersync.dart homepage: https://www.powersync.com/ environment: @@ -10,9 +10,8 @@ dependencies: flutter: sdk: flutter - powersync_core: ^1.5.0 + powersync_core: ^1.6.1 logging: ^1.2.0 - sqlite_async: ^0.11.0 path_provider: ^2.0.13 dev_dependencies: diff --git a/packages/powersync_core/CHANGELOG.md b/packages/powersync_core/CHANGELOG.md index ee22df4d..4ba23772 100644 --- a/packages/powersync_core/CHANGELOG.md +++ b/packages/powersync_core/CHANGELOG.md @@ -1,3 +1,28 @@ +## 1.6.1 + + - Web: Fix decoding sync streams on status. + + - **DOCS**: Point to uses in example. ([4f4da24e](https://github.com/powersync-ja/powersync.dart/commit/4f4da24e580dec6b1d29a5e0907b83ba7c55e3d8)) + +## 1.6.0 + +- Add `getCrudTransactions()` returning a stream of completed transactions for uploads. +- Add experimental support for [sync streams](https://docs.powersync.com/usage/sync-streams). +- Add new attachments helper implementation in `package:powersync_core/attachments/attachments.dart`. +- Add SwiftPM support. +- Add support for compiling `powersync_core` with `build_web_compilers`. + +## 1.5.2 + + - Fix excessive memory consumption during large sync. + +## 1.5.1 + + - Support latest versions of `package:sqlite3` and `package:sqlite_async`. + - Stream client: Improve `disconnect()` while a connection is being opened. + - Stream client: Support binary sync lines with Rust client and compatible PowerSync service versions. + - Sync client: Improve parsing error responses. + ## 1.5.0 - Update the PowerSync core extension to `0.4.2`. diff --git a/packages/powersync_core/dartdoc_options.yaml b/packages/powersync_core/dartdoc_options.yaml new file mode 100644 index 00000000..4fe2c4e0 --- /dev/null +++ b/packages/powersync_core/dartdoc_options.yaml @@ -0,0 +1,5 @@ +dartdoc: + categories: + attachments: + name: Attachments + markdown: doc/attachments.md diff --git a/packages/powersync_core/doc/attachments.md b/packages/powersync_core/doc/attachments.md new file mode 100644 index 00000000..6919430f --- /dev/null +++ b/packages/powersync_core/doc/attachments.md @@ -0,0 +1,135 @@ +## Attachments + +In many cases, you might want to sync large binary data (like images) along with the data synced by +PowerSync. +Embedding this data directly in your source databases is [inefficient and not recommended](https://docs.powersync.com/usage/use-case-examples/attachments). + +Instead, the PowerSync SDK for Dart and Flutter provides utilities you can use to _reference_ this binary data +in your regular database schema, and then download it from a secondary data store such as Supabase Storage or S3. +Because binary data is not directly stored in the source database in this model, we call these files _attachments_. + + +> [!NOTE] +> These attachment utilities are recommended over our legacy [PowerSync Attachments Helper](https://pub.dev/packages/powersync_attachments_helper) package. The new utilities provide cleaner APIs that are more aligned with similar helpers in our other SDKs, and include improved features such as: +> - Support for writing your own local storage implementation +> - Support for dynamic nested directories and custom per-attachment file extensions out of the box +> - Ability to add optional `metaData` to attachments +> - No longer depends on `dart:io` +> +> If you are new to handling attachments, we recommend starting with these utilities. If you currently use the legacy `powersync_attachments_helper` package, a fairly simple migration would be to adopt the new utilities with a different table name and drop the legacy package. This means existing attachments are lost, but they should be downloaded again. + +## Alpha release + +The attachment utilities described in this document are currently in an alpha state, intended for testing. +Expect breaking changes and instability as development continues. +The attachments API is marked as `@experimental` for this reason. + +Do not rely on these utilities for production use. + +## Usage + +An `AttachmentQueue` instance is used to manage and sync attachments in your app. +The attachments' state is stored in a local-only attachments table. + +### Key assumptions + +- Each attachment is identified by a unique id. +- Attachments are immutable once created. +- Relational data should reference attachments using a foreign key column. +- Relational data should reflect the holistic state of attachments at any given time. Any existing local attachment + will be deleted locally if no relational data references it. + +### Example implementation + +See the [supabase-todolist](https://github.com/powersync-ja/powersync.dart/tree/main/demos/supabase-todolist) demo for a basic example of attachment syncing. + +In particular, relevant snippets from that example are: + +- The attachment queue is set up [here](https://github.com/powersync-ja/powersync.dart/blob/98d73e2f157a697786373fef755576505abc74a5/demos/supabase-todolist/lib/attachments/queue.dart#L16-L36), using conditional imports to store attachments in the file system on native platforms and in-memory for web. +- When a new attachment is added, `saveFile` is called [here](https://github.com/powersync-ja/powersync.dart/blob/98d73e2f157a697786373fef755576505abc74a5/demos/supabase-todolist/lib/attachments/queue.dart#L38-L55) and automatically updates references in the main schema to reference the attachment. + +### Setup + +First, add a table storing local attachment state to your database schema. + +```dart +final schema = Schema([ + AttachmentsQueueTable(), + // In this document, we assume the photo_id column of the todos table references an optional photo + // stored as an attachment. + Table('todos', [ + Column.text('list_id'), + Column.text('photo_id'), + Column.text('description'), + Column.integer('completed'), + ]), +]); +``` + +Next, create an `AttachmentQueue` instance. This class provides default syncing utilities and implements a default +sync strategy. This class can be extended for custom functionality, if needed. + +```dart +final directory = await getApplicationDocumentsDirectory(); + +final attachmentQueue = AttachmentQueue( + db: db, + remoteStorage: SupabaseStorageAdapter(), // instance responsible for uploads and downloads + logger: logger, + localStorage: IOLocalStorage(appDocDir), // IOLocalStorage requires `dart:io` and is not available on the web + watchAttachments: () => db.watch(''' + SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL + ''').map((results) => [ + for (final row in results) + WatchedAttachmentItem( + id: row['id'] as String, + fileExtension: 'jpg', + ) + ], + ), +); +``` + +Here, + - An instance of `LocalStorageAdapter`, such as the `IOLocalStorage` provided by the SDK, is responsible for storing + attachment contents locally. + - An instance of `RemoteStorageAdapter` is responsible for downloading and uploading attachment contents to the secondary + service, such as S3, Firebase cloud storage or Supabase storage. + - `watchAttachments` is a function emitting a stream of attachment items that are considered to be referenced from + the current database state. In this example, `todos.photo_id` is the only column referencing attachments. + +Next, start the sync process by calling `attachmentQueue.startSync()`. + +## Storing attachments + +To create a new attachment locally, call `AttachmentQueue.saveFile`. To represent the attachment, this method takes +the contents to store, the media type, an optional file extension and id. +The queue will store the contents in a local file and mark it as queued for upload. It also invokes a callback +responsible for referencing the id of the generated attachment in the primary data model: + +```dart +Future savePhotoAttachment( + Stream> photoData, String todoId, + {String mediaType = 'image/jpeg'}) async { + // Save the file using the AttachmentQueue API + return await attachmentQueue.saveFile( + data: photoData, + mediaType: mediaType, + fileExtension: 'jpg', + metaData: 'Photo attachment for todo: $todoId', + updateHook: (context, attachment) async { + // Update the todo item to reference this attachment + await context.execute( + 'UPDATE todos SET photo_id = ? WHERE id = ?', + [attachment.id, todoId], + ); + }, + ); +} +``` + +## Deleting attachments + +To delete attachments, it is sufficient to stop referencing them in the data model, e.g. via +`UPDATE todos SET photo_id = NULL` in this example. The attachment sync implementation will eventually +delete orphaned attachments from the local storage. diff --git a/packages/powersync_core/lib/attachments/attachments.dart b/packages/powersync_core/lib/attachments/attachments.dart new file mode 100644 index 00000000..a69f9409 --- /dev/null +++ b/packages/powersync_core/lib/attachments/attachments.dart @@ -0,0 +1,12 @@ +/// Imports for attachments that are available on all platforms. +/// +/// For more details on using attachments, see the documentation for the topic. +/// +/// {@category attachments} +library; + +export '../src/attachments/attachment.dart'; +export '../src/attachments/attachment_queue_service.dart'; +export '../src/attachments/local_storage.dart'; +export '../src/attachments/remote_storage.dart'; +export '../src/attachments/sync_error_handler.dart'; diff --git a/packages/powersync_core/lib/attachments/io.dart b/packages/powersync_core/lib/attachments/io.dart new file mode 100644 index 00000000..142abb26 --- /dev/null +++ b/packages/powersync_core/lib/attachments/io.dart @@ -0,0 +1,12 @@ +/// A platform-specific import supporting attachments on native platforms. +/// +/// This library exports the [IOLocalStorage] class, implementing the +/// [LocalStorage] interface by storing files under a root directory. +/// +/// {@category attachments} +library; + +import '../src/attachments/io_local_storage.dart'; +import '../src/attachments/local_storage.dart'; + +export '../src/attachments/io_local_storage.dart'; diff --git a/packages/powersync_core/lib/powersync_core.dart b/packages/powersync_core/lib/powersync_core.dart index b4dfe35d..ef2e97c7 100644 --- a/packages/powersync_core/lib/powersync_core.dart +++ b/packages/powersync_core/lib/powersync_core.dart @@ -11,6 +11,7 @@ export 'src/log.dart'; export 'src/open_factory.dart'; export 'src/schema.dart'; export 'src/sync/options.dart' hide ResolvedSyncOptions; +export 'src/sync/stream.dart' hide CoreActiveStreamSubscription; export 'src/sync/sync_status.dart' - hide BucketProgress, InternalSyncDownloadProgress; + hide BucketProgress, InternalSyncDownloadProgress, InternalSyncStatusAccess; export 'src/uuid.dart'; diff --git a/packages/powersync_core/lib/src/attachments/attachment.dart b/packages/powersync_core/lib/src/attachments/attachment.dart new file mode 100644 index 00000000..00f0032a --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/attachment.dart @@ -0,0 +1,197 @@ +/// Defines attachment states and the Attachment model for the PowerSync +/// attachments system. +/// +/// Includes metadata, state, and utility methods for working with attachments. +library; + +import 'package:meta/meta.dart'; +import 'package:powersync_core/sqlite3_common.dart' show Row; +import 'package:powersync_core/powersync_core.dart'; + +/// Represents the state of an attachment. +/// +/// {@category attachments} +@experimental +enum AttachmentState { + /// The attachment is queued for download from the remote storage. + queuedDownload, + + /// The attachment is queued for upload to the remote storage. + queuedUpload, + + /// The attachment is queued for deletion from the remote storage. + queuedDelete, + + /// The attachment is fully synchronized with the remote storage. + synced, + + /// The attachment is archived and no longer actively synchronized. + archived; + + /// Constructs an [AttachmentState] from the corresponding integer value. + /// + /// Throws [ArgumentError] if the value does not match any [AttachmentState]. + static AttachmentState fromInt(int value) { + if (value < 0 || value >= AttachmentState.values.length) { + throw ArgumentError('Invalid value for AttachmentState: $value'); + } + return AttachmentState.values[value]; + } + + /// Returns the ordinal value of this [AttachmentState]. + int toInt() => index; +} + +/// Represents an attachment with metadata and state information. +/// +/// {@category Attachments} +/// +/// Properties: +/// - [id]: Unique identifier for the attachment. +/// - [timestamp]: Timestamp of the last record update. +/// - [filename]: Name of the attachment file, e.g., `[id].jpg`. +/// - [state]: Current state of the attachment, represented as an ordinal of [AttachmentState]. +/// - [localUri]: Local URI pointing to the attachment file, if available. +/// - [mediaType]: Media type of the attachment, typically represented as a MIME type. +/// - [size]: Size of the attachment in bytes, if available. +/// - [hasSynced]: Indicates whether the attachment has been synced locally before. +/// - [metaData]: Additional metadata associated with the attachment. +/// +/// {@category attachments} +@experimental +final class Attachment { + /// Unique identifier for the attachment. + final String id; + + /// Timestamp of the last record update. + final int timestamp; + + /// Name of the attachment file, e.g., `[id].jpg`. + final String filename; + + /// Current state of the attachment, represented as an ordinal of [AttachmentState]. + final AttachmentState state; + + /// Local URI pointing to the attachment file, if available. + final String? localUri; + + /// Media type of the attachment, typically represented as a MIME type. + final String? mediaType; + + /// Size of the attachment in bytes, if available. + final int? size; + + /// Indicates whether the attachment has been synced locally before. + final bool hasSynced; + + /// Additional metadata associated with the attachment. + final String? metaData; + + /// Creates an [Attachment] instance. + const Attachment({ + required this.id, + this.timestamp = 0, + required this.filename, + this.state = AttachmentState.queuedDownload, + this.localUri, + this.mediaType, + this.size, + this.hasSynced = false, + this.metaData, + }); + + /// Creates an [Attachment] instance from a database row. + /// + /// [row]: The [Row] containing attachment data. + /// Returns an [Attachment] instance populated with data from the row. + factory Attachment.fromRow(Row row) { + return Attachment( + id: row['id'] as String, + timestamp: row['timestamp'] as int? ?? 0, + filename: row['filename'] as String, + localUri: row['local_uri'] as String?, + mediaType: row['media_type'] as String?, + size: row['size'] as int?, + state: AttachmentState.fromInt(row['state'] as int), + hasSynced: (row['has_synced'] as int? ?? 0) > 0, + metaData: row['meta_data']?.toString(), + ); + } + + /// Returns a copy of this attachment with the given fields replaced. + Attachment copyWith({ + String? id, + int? timestamp, + String? filename, + AttachmentState? state, + String? localUri, + String? mediaType, + int? size, + bool? hasSynced, + String? metaData, + }) { + return Attachment( + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + filename: filename ?? this.filename, + state: state ?? this.state, + localUri: localUri ?? this.localUri, + mediaType: mediaType ?? this.mediaType, + size: size ?? this.size, + hasSynced: hasSynced ?? this.hasSynced, + metaData: metaData ?? this.metaData, + ); + } + + Attachment markAsUnavailableLocally(AttachmentState newState) { + return Attachment( + id: id, + timestamp: timestamp, + filename: filename, + state: newState, + localUri: null, + mediaType: mediaType, + size: size, + hasSynced: false, + metaData: metaData, + ); + } + + @override + String toString() { + return 'Attachment(id: $id, state: $state, localUri: $localUri, metadata: $metaData)'; + } +} + +/// Table definition for the attachments queue. +/// +/// The columns in this table are used by the attachments implementation to +/// store which attachments have been download and tracks metadata for state. +/// +/// {@category attachments} +@experimental +final class AttachmentsQueueTable extends Table { + AttachmentsQueueTable({ + String attachmentsQueueTableName = defaultTableName, + List additionalColumns = const [], + List indexes = const [], + String? viewName, + }) : super.localOnly( + attachmentsQueueTableName, + [ + const Column.text('filename'), + const Column.text('local_uri'), + const Column.integer('timestamp'), + const Column.integer('size'), + const Column.text('media_type'), + const Column.integer('state'), + const Column.integer('has_synced'), + const Column.text('meta_data'), + ...additionalColumns, + ], + viewName: viewName, + indexes: indexes, + ); + + static const defaultTableName = 'attachments_queue'; +} diff --git a/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart b/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart new file mode 100644 index 00000000..0ec673e0 --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart @@ -0,0 +1,443 @@ +// Implements the attachment queue for PowerSync attachments. +// +// This class manages the lifecycle of attachment records, including watching for new attachments, +// syncing with remote storage, handling uploads, downloads, and deletes, and managing local storage. +// It provides hooks for error handling, cache management, and custom filename resolution. + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:powersync_core/powersync_core.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import 'attachment.dart'; +import 'implementations/attachment_context.dart'; +import 'local_storage.dart'; +import 'remote_storage.dart'; +import 'sync_error_handler.dart'; +import 'implementations/attachment_service.dart'; +import 'sync/syncing_service.dart'; + +/// A watched attachment record item. +/// +/// This is usually returned from watching all relevant attachment IDs. +/// +/// - [id]: Id for the attachment record. +/// - [fileExtension]: File extension used to determine an internal filename for storage if no [filename] is provided. +/// - [filename]: Filename to store the attachment with. +/// - [metaData]: Optional metadata for the attachment record. +/// +/// {@category attachments} +@experimental +final class WatchedAttachmentItem { + /// Id for the attachment record. + final String id; + + /// File extension used to determine an internal filename for storage if no [filename] is provided. + final String? fileExtension; + + /// Filename to store the attachment with. + final String? filename; + + /// Optional metadata for the attachment record. + final String? metaData; + + /// Creates a [WatchedAttachmentItem]. + /// + /// Either [fileExtension] or [filename] must be provided. + const WatchedAttachmentItem({ + required this.id, + this.fileExtension, + this.filename, + this.metaData, + }) : assert( + fileExtension != null || filename != null, + 'Either fileExtension or filename must be provided.', + ); +} + +/// Class used to implement the attachment queue. +/// +/// Manages the lifecycle of attachment records, including watching for new attachments, +/// syncing with remote storage, handling uploads, downloads, and deletes, and managing local storage. +/// +/// {@category attachments} +@experimental +base class AttachmentQueue { + final PowerSyncDatabase _db; + final Stream> Function() _watchAttachments; + final LocalStorage _localStorage; + final bool _downloadAttachments; + final Logger _logger; + + final Mutex _mutex = Mutex(); + bool _closed = false; + StreamSubscription? _syncStatusSubscription; + StreamSubscription? _watchedAttachmentsSubscription; + final AttachmentService _attachmentsService; + final SyncingService _syncingService; + + AttachmentQueue._( + {required PowerSyncDatabase db, + required Stream> Function() watchAttachments, + required LocalStorage localStorage, + required bool downloadAttachments, + required Logger logger, + required AttachmentService attachmentsService, + required SyncingService syncingService}) + : _db = db, + _watchAttachments = watchAttachments, + _localStorage = localStorage, + _downloadAttachments = downloadAttachments, + _logger = logger, + _attachmentsService = attachmentsService, + _syncingService = syncingService; + + /// Creates a new attachment queue. + /// + /// Parameters: + /// + /// - [db]: PowerSync database client. + /// - [remoteStorage]: Adapter which interfaces with the remote storage backend. + /// - [watchAttachments]: A stream generator for the current state of local attachments. + /// - [localStorage]: Provides access to local filesystem storage methods. + /// - [attachmentsQueueTableName]: SQLite table where attachment state will be recorded. + /// - [errorHandler]: Attachment operation error handler. Specifies if failed attachment operations should be retried. + /// - [syncInterval]: Periodic interval to trigger attachment sync operations. + /// - [archivedCacheLimit]: Defines how many archived records are retained as a cache. + /// - [syncThrottleDuration]: Throttles remote sync operations triggering. + /// - [downloadAttachments]: Should attachments be downloaded. + /// - [logger]: Logging interface used for all log operations. + factory AttachmentQueue({ + required PowerSyncDatabase db, + required RemoteStorage remoteStorage, + required Stream> Function() watchAttachments, + required LocalStorage localStorage, + String attachmentsQueueTableName = AttachmentsQueueTable.defaultTableName, + AttachmentErrorHandler? errorHandler, + Duration syncInterval = const Duration(seconds: 30), + int archivedCacheLimit = 100, + Duration syncThrottleDuration = const Duration(seconds: 1), + bool downloadAttachments = true, + Logger? logger, + }) { + final resolvedLogger = logger ?? db.logger; + + final attachmentsService = AttachmentService( + db: db, + logger: resolvedLogger, + maxArchivedCount: archivedCacheLimit, + attachmentsQueueTableName: attachmentsQueueTableName, + ); + final syncingService = SyncingService( + remoteStorage: remoteStorage, + localStorage: localStorage, + attachmentsService: attachmentsService, + errorHandler: errorHandler, + syncThrottle: syncThrottleDuration, + period: syncInterval, + logger: resolvedLogger, + ); + + return AttachmentQueue._( + db: db, + watchAttachments: watchAttachments, + localStorage: localStorage, + downloadAttachments: downloadAttachments, + logger: resolvedLogger, + attachmentsService: attachmentsService, + syncingService: syncingService, + ); + } + + /// Initialize the attachment queue by: + /// 1. Creating the attachments directory. + /// 2. Adding watches for uploads, downloads, and deletes. + /// 3. Adding a trigger to run uploads, downloads, and deletes when the device is online after being offline. + Future startSync() async { + await _mutex.lock(() async { + if (_closed) { + throw StateError('Attachment queue has been closed'); + } + + await _stopSyncingInternal(); + + await _localStorage.initialize(); + + await _attachmentsService.withContext((context) async { + await _verifyAttachments(context); + }); + + // Listen for connectivity changes and watched attachments + await _syncingService.startSync(); + + _watchedAttachmentsSubscription = + _watchAttachments().listen((items) async { + await _processWatchedAttachments(items); + }); + + var previouslyConnected = _db.currentStatus.connected; + _syncStatusSubscription = _db.statusStream.listen((status) { + if (!previouslyConnected && status.connected) { + _syncingService.triggerSync(); + } + + previouslyConnected = status.connected; + }); + _watchAttachments().listen((items) async { + await _processWatchedAttachments(items); + }); + + _logger.info('AttachmentQueue started syncing.'); + }); + } + + /// Stops syncing. Syncing may be resumed with [startSync]. + Future stopSyncing() async { + await _mutex.lock(() async { + await _stopSyncingInternal(); + }); + } + + Future _stopSyncingInternal() async { + if (_closed || + _syncStatusSubscription == null || + _watchedAttachmentsSubscription == null) { + return; + } + + await ( + _syncStatusSubscription!.cancel(), + _watchedAttachmentsSubscription!.cancel(), + ).wait; + + _syncStatusSubscription = null; + _watchedAttachmentsSubscription = null; + await _syncingService.stopSync(); + + _logger.info('AttachmentQueue stopped syncing.'); + } + + /// Closes the queue. The queue cannot be used after closing. + Future close() async { + await _mutex.lock(() async { + if (_closed) return; + + await _stopSyncingInternal(); + _closed = true; + _logger.info('AttachmentQueue closed.'); + }); + } + + /// Resolves the filename for new attachment items. + /// Concatenates the attachment ID and extension by default. + Future resolveNewAttachmentFilename( + String attachmentId, + String? fileExtension, + ) async { + return '$attachmentId.${fileExtension ?? 'dat'}'; + } + + /// Processes attachment items returned from `watchAttachments`. + /// + /// The default implementation asserts the items returned from + /// `watchAttachments` as the definitive state for local attachments. + Future _processWatchedAttachments( + List items, + ) async { + await _attachmentsService.withContext((context) async { + final currentAttachments = await context.getAttachments(); + final List attachmentUpdates = []; + + for (final item in items) { + final existingQueueItem = + currentAttachments.where((a) => a.id == item.id).firstOrNull; + + if (existingQueueItem == null) { + if (!_downloadAttachments) continue; + + // This item should be added to the queue. + // This item is assumed to be coming from an upstream sync. + final String filename = item.filename ?? + await resolveNewAttachmentFilename(item.id, item.fileExtension); + + attachmentUpdates.add( + Attachment( + id: item.id, + filename: filename, + state: AttachmentState.queuedDownload, + metaData: item.metaData, + ), + ); + } else if (existingQueueItem.state == AttachmentState.archived) { + // The attachment is present again. Need to queue it for sync. + if (existingQueueItem.hasSynced) { + // No remote action required, we can restore the record (avoids deletion). + attachmentUpdates.add( + existingQueueItem.copyWith(state: AttachmentState.synced), + ); + } else { + // The localURI should be set if the record was meant to be downloaded + // and has been synced. If it's missing and hasSynced is false then + // it must be an upload operation. + attachmentUpdates.add( + existingQueueItem.copyWith( + state: existingQueueItem.localUri == null + ? AttachmentState.queuedDownload + : AttachmentState.queuedUpload, + ), + ); + } + } + } + + // Archive any items not specified in the watched items. + // For queuedDelete or queuedUpload states, archive only if hasSynced is true. + // For other states, archive if the record is not found in the items. + for (final attachment in currentAttachments) { + final notInWatchedItems = items.every( + (update) => update.id != attachment.id, + ); + + if (notInWatchedItems) { + switch (attachment.state) { + case AttachmentState.queuedDelete: + case AttachmentState.queuedUpload: + if (attachment.hasSynced) { + attachmentUpdates.add( + attachment.copyWith(state: AttachmentState.archived), + ); + } + default: + attachmentUpdates.add( + attachment.copyWith(state: AttachmentState.archived), + ); + } + } + } + + await context.saveAttachments(attachmentUpdates); + }); + } + + /// Generates a random attachment id. + Future generateAttachmentId() async { + final row = await _db.get('SELECT uuid() as id'); + return row['id'] as String; + } + + /// Creates a new attachment locally and queues it for upload. + /// The filename is resolved using [resolveNewAttachmentFilename]. + Future saveFile({ + required Stream> data, + required String mediaType, + String? fileExtension, + String? metaData, + String? id, + required Future Function( + SqliteWriteContext context, Attachment attachment) + updateHook, + }) async { + final resolvedId = id ?? await generateAttachmentId(); + + final filename = await resolveNewAttachmentFilename( + resolvedId, + fileExtension, + ); + + // Write the file to the filesystem. + final fileSize = await _localStorage.saveFile(filename, data); + + return await _attachmentsService.withContext((attachmentContext) async { + return await _db.writeTransaction((tx) async { + final attachment = Attachment( + id: resolvedId, + filename: filename, + size: fileSize, + mediaType: mediaType, + state: AttachmentState.queuedUpload, + localUri: filename, + metaData: metaData, + ); + + // Allow consumers to set relationships to this attachment ID. + await updateHook(tx, attachment); + + return await attachmentContext.upsertAttachment(attachment, tx); + }); + }); + } + + /// Queues an attachment for delete. + /// The default implementation assumes the attachment record already exists locally. + Future deleteFile({ + required String attachmentId, + required Future Function( + SqliteWriteContext context, Attachment attachment) + updateHook, + }) async { + return await _attachmentsService.withContext((attachmentContext) async { + final attachment = await attachmentContext.getAttachment(attachmentId); + if (attachment == null) { + throw Exception( + 'Attachment record with id $attachmentId was not found.', + ); + } + + return await _db.writeTransaction((tx) async { + await updateHook(tx, attachment); + return await attachmentContext.upsertAttachment( + attachment.copyWith( + state: AttachmentState.queuedDelete, + hasSynced: false, + ), + tx, + ); + }); + }); + } + + /// Removes all archived items. + Future expireCache() async { + await _attachmentsService.withContext((context) async { + bool done; + do { + done = await _syncingService.deleteArchivedAttachments(context); + } while (!done); + }); + } + + /// Clears the attachment queue and deletes all attachment files. + Future clearQueue() async { + await _attachmentsService.withContext((context) async { + await context.clearQueue(); + }); + await _localStorage.clear(); + } + + /// Cleans up stale attachments. + Future _verifyAttachments(AttachmentContext context) async { + final attachments = await context.getActiveAttachments(); + final List updates = []; + + for (final attachment in attachments) { + // Only check attachments that should have local files + if (attachment.localUri == null) { + // Skip attachments that don't have localUri (like queued downloads) + continue; + } + + final exists = await _localStorage.fileExists(attachment.localUri!); + if ((attachment.state == AttachmentState.synced || + attachment.state == AttachmentState.queuedUpload) && + !exists) { + updates.add( + attachment.markAsUnavailableLocally(AttachmentState.archived), + ); + } + } + + await context.saveAttachments(updates); + } +} diff --git a/packages/powersync_core/lib/src/attachments/implementations/attachment_context.dart b/packages/powersync_core/lib/src/attachments/implementations/attachment_context.dart new file mode 100644 index 00000000..9428d79c --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/implementations/attachment_context.dart @@ -0,0 +1,160 @@ +import 'package:powersync_core/powersync_core.dart'; +import 'package:powersync_core/sqlite3_common.dart'; +import 'package:logging/logging.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:meta/meta.dart'; + +import '../attachment.dart'; + +@internal +final class AttachmentContext { + final PowerSyncDatabase db; + final Logger log; + final int maxArchivedCount; + final String attachmentsQueueTableName; + + AttachmentContext( + this.db, + this.log, + this.maxArchivedCount, + this.attachmentsQueueTableName, + ); + + /// Table used for storing attachments in the attachment queue. + String get table { + return attachmentsQueueTableName; + } + + Future deleteAttachment(String id) async { + log.info('deleteAttachment: $id'); + await db.writeTransaction((tx) async { + await tx.execute('DELETE FROM $table WHERE id = ?', [id]); + }); + } + + Future ignoreAttachment(String id) async { + await db.execute( + 'UPDATE $table SET state = ${AttachmentState.archived.index} WHERE id = ?', + [id], + ); + } + + Future getAttachment(String id) async { + final row = await db.getOptional('SELECT * FROM $table WHERE id = ?', [id]); + if (row == null) { + return null; + } + return Attachment.fromRow(row); + } + + Future saveAttachment(Attachment attachment) async { + return await db.writeLock((ctx) async { + return await upsertAttachment(attachment, ctx); + }); + } + + Future saveAttachments(List attachments) async { + if (attachments.isEmpty) { + log.finer('No attachments to save.'); + return; + } + await db.writeTransaction((tx) async { + for (final attachment in attachments) { + await upsertAttachment(attachment, tx); + } + }); + } + + Future> getAttachmentIds() async { + ResultSet results = await db.getAll( + 'SELECT id FROM $table WHERE id IS NOT NULL', + ); + + List ids = results.map((row) => row['id'] as String).toList(); + + return ids; + } + + Future> getAttachments() async { + final results = await db.getAll('SELECT * FROM $table'); + return results.map((row) => Attachment.fromRow(row)).toList(); + } + + Future> getActiveAttachments() async { + // Return all attachments that are not archived (i.e., state != AttachmentState.archived) + final results = await db.getAll('SELECT * FROM $table WHERE state != ?', [ + AttachmentState.archived.index, + ]); + return results.map((row) => Attachment.fromRow(row)).toList(); + } + + Future clearQueue() async { + log.info('Clearing attachment queue...'); + await db.execute('DELETE FROM $table'); + } + + Future deleteArchivedAttachments( + Future Function(List) callback, + ) async { + // Only delete archived attachments exceeding the maxArchivedCount, ordered by timestamp DESC + const limit = 1000; + + final results = await db.getAll( + 'SELECT * FROM $table WHERE state = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?', + [ + AttachmentState.archived.index, + limit, + maxArchivedCount, + ], + ); + final archivedAttachments = + results.map((row) => Attachment.fromRow(row)).toList(); + + if (archivedAttachments.isEmpty) { + return false; + } + + log.info( + 'Deleting ${archivedAttachments.length} archived attachments (exceeding maxArchivedCount=$maxArchivedCount)...'); + // Call the callback with the list of archived attachments before deletion + await callback(archivedAttachments); + + // Delete the archived attachments from the table + final ids = archivedAttachments.map((a) => a.id).toList(); + if (ids.isNotEmpty) { + await db.executeBatch('DELETE FROM $table WHERE id = ?', [ + for (final id in ids) [id], + ]); + } + + log.info('Deleted ${archivedAttachments.length} archived attachments.'); + return archivedAttachments.length < limit; + } + + Future upsertAttachment( + Attachment attachment, + SqliteWriteContext context, + ) async { + log.finest('Updating attachment ${attachment.id}: ${attachment.state}'); + + await context.execute( + '''INSERT OR REPLACE INTO + $table (id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?)''', + [ + attachment.id, + attachment.timestamp, + attachment.filename, + attachment.localUri, + attachment.mediaType, + attachment.size, + attachment.state.index, + attachment.hasSynced ? 1 : 0, + attachment.metaData, + ], + ); + + return attachment; + } +} diff --git a/packages/powersync_core/lib/src/attachments/implementations/attachment_service.dart b/packages/powersync_core/lib/src/attachments/implementations/attachment_service.dart new file mode 100644 index 00000000..0ccafd75 --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/implementations/attachment_service.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync_core/powersync_core.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../attachment.dart'; +import 'attachment_context.dart'; + +@internal +final class AttachmentService { + final PowerSyncDatabase db; + final Logger logger; + final int maxArchivedCount; + final String attachmentsQueueTableName; + final Mutex _mutex = Mutex(); + + late final AttachmentContext _context; + + AttachmentService({ + required this.db, + required this.logger, + required this.maxArchivedCount, + required this.attachmentsQueueTableName, + }) { + _context = AttachmentContext( + db, + logger, + maxArchivedCount, + attachmentsQueueTableName, + ); + } + + Stream watchActiveAttachments({Duration? throttle}) async* { + logger.info('Watching attachments...'); + + // Watch for attachments with active states (queued for upload, download, or delete) + final stream = db.watch( + ''' + SELECT + id + FROM + $attachmentsQueueTableName + WHERE + state = ? + OR state = ? + OR state = ? + ORDER BY + timestamp ASC + ''', + parameters: [ + AttachmentState.queuedUpload.index, + AttachmentState.queuedDownload.index, + AttachmentState.queuedDelete.index, + ], + throttle: throttle ?? const Duration(milliseconds: 30), + ); + + yield* stream; + } + + Future withContext( + Future Function(AttachmentContext ctx) action, + ) async { + return await _mutex.lock(() async { + try { + return await action(_context); + } catch (e, stackTrace) { + // Re-throw the error to be handled by the caller + Error.throwWithStackTrace(e, stackTrace); + } + }); + } +} diff --git a/packages/powersync_core/lib/src/attachments/io_local_storage.dart b/packages/powersync_core/lib/src/attachments/io_local_storage.dart new file mode 100644 index 00000000..67d1b578 --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/io_local_storage.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +import 'local_storage.dart'; + +/// Implements [LocalStorage] for device filesystem using Dart IO. +/// +/// Handles file and directory operations for attachments. The database only +/// stores relative paths for attachments that this implementation resolves +/// against the root path provided as a constructor argument. For that reason, +/// it's important that the root directory stays consistent, as data may be lost +/// otherwise. +/// +/// {@category attachments} +@experimental +final class IOLocalStorage implements LocalStorage { + final Directory _root; + + const IOLocalStorage(this._root); + + File _fileFor(String filePath) => File(p.join(_root.path, filePath)); + + @override + Future saveFile(String filePath, Stream> data) async { + final file = _fileFor(filePath); + await file.parent.create(recursive: true); + return (await data.pipe(_LengthTrackingSink(file.openWrite()))) as int; + } + + @override + Stream readFile(String filePath, {String? mediaType}) async* { + final file = _fileFor(filePath); + if (!await file.exists()) { + throw FileSystemException('File does not exist', filePath); + } + final source = file.openRead(); + await for (final chunk in source) { + yield chunk is Uint8List ? chunk : Uint8List.fromList(chunk); + } + } + + @override + Future deleteFile(String filePath) async { + final file = _fileFor(filePath); + if (await file.exists()) { + await file.delete(); + } + } + + @override + Future fileExists(String filePath) async { + return await _fileFor(filePath).exists(); + } + + /// Creates a directory and all necessary parent directories dynamically if they do not exist. + @override + Future initialize() async { + await _root.create(recursive: true); + } + + @override + Future clear() async { + if (await _root.exists()) { + await _root.delete(recursive: true); + } + await _root.create(recursive: true); + } +} + +final class _LengthTrackingSink implements StreamConsumer> { + final StreamConsumer> inner; + var bytesWritten = 0; + + _LengthTrackingSink(this.inner); + + @override + Future addStream(Stream> stream) { + return inner.addStream(stream.map((event) { + bytesWritten += event.length; + return event; + })); + } + + @override + Future close() async { + await inner.close(); + return bytesWritten; + } +} diff --git a/packages/powersync_core/lib/src/attachments/local_storage.dart b/packages/powersync_core/lib/src/attachments/local_storage.dart new file mode 100644 index 00000000..c43db1ef --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/local_storage.dart @@ -0,0 +1,102 @@ +/// @docImport 'package:powersync_core/attachments/io.dart'; +library; + +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// An interface responsible for storing attachment data locally. +/// +/// This interface is only responsible for storing attachment content, +/// essentially acting as a key-value store of virtual paths to blobs. +/// +/// On native platforms, you can use the [IOLocalStorage] implemention. On the +/// web, no default implementation is available at the moment. +/// +/// {@category attachments} +@experimental +abstract interface class LocalStorage { + /// Returns an in-memory [LocalStorage] implementation, suitable for testing. + factory LocalStorage.inMemory() = _InMemoryStorage; + + /// Saves binary data stream to storage at the specified file path + /// + /// [filePath] - Path where the file will be stored + /// [data] - List of binary data to store + /// Returns the total size of the written data in bytes + Future saveFile(String filePath, Stream> data); + + /// Retrieves binary data stream from storage at the specified file path + /// + /// [filePath] - Path of the file to read + /// + /// Returns a stream of binary data + Stream readFile(String filePath); + + /// Deletes a file at the specified path + /// + /// [filePath] - Path of the file to delete + Future deleteFile(String filePath); + + /// Checks if a file exists at the specified path + /// + /// [filePath] - Path to check + /// + /// Returns true if the file exists, false otherwise + Future fileExists(String filePath); + + /// Initializes the storage, performing any necessary setup. + Future initialize(); + + /// Clears all data from the storage. + Future clear(); +} + +final class _InMemoryStorage implements LocalStorage { + final Map content = {}; + + String _keyForPath(String path) { + return p.normalize(path); + } + + @override + Future clear() async { + content.clear(); + } + + @override + Future deleteFile(String filePath) async { + content.remove(_keyForPath(filePath)); + } + + @override + Future fileExists(String filePath) async { + return content.containsKey(_keyForPath(filePath)); + } + + @override + Future initialize() async {} + + @override + Stream readFile(String filePath) { + return switch (content[_keyForPath(filePath)]) { + null => + Stream.error('file at $filePath does not exist in in-memory storage'), + final contents => Stream.value(contents), + }; + } + + @override + Future saveFile(String filePath, Stream> data) async { + var length = 0; + final builder = BytesBuilder(copy: false); + await for (final chunk in data) { + length += chunk.length; + builder.add(chunk); + } + + content[_keyForPath(filePath)] = builder.takeBytes(); + return length; + } +} diff --git a/packages/powersync_core/lib/src/attachments/remote_storage.dart b/packages/powersync_core/lib/src/attachments/remote_storage.dart new file mode 100644 index 00000000..35818b9b --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/remote_storage.dart @@ -0,0 +1,34 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import 'attachment.dart'; + +/// An interface responsible for uploading and downloading attachments from a +/// remote source, like e.g. S3 or Firebase cloud storage. +/// +/// {@category attachments} +@experimental +abstract interface class RemoteStorage { + /// Uploads a file to remote storage. + /// + /// [fileData] is a stream of byte arrays representing the file data. + /// [attachment] is the attachment record associated with the file. + Future uploadFile( + Stream fileData, + Attachment attachment, + ); + + /// Downloads a file from remote storage. + /// + /// [attachment] is the attachment record associated with the file. + /// + /// Returns a stream of byte arrays representing the file data. + Future>> downloadFile(Attachment attachment); + + /// Deletes a file from remote storage. + /// + /// [attachment] is the attachment record associated with the file. + Future deleteFile(Attachment attachment); +} diff --git a/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart b/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart new file mode 100644 index 00000000..4bc1a266 --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:logging/logging.dart'; +import 'package:async/async.dart'; + +import '../attachment.dart'; +import '../implementations/attachment_context.dart'; +import '../implementations/attachment_service.dart'; +import '../local_storage.dart'; +import '../remote_storage.dart'; +import '../sync_error_handler.dart'; + +/// SyncingService is responsible for syncing attachments between local and remote storage. +/// +/// This service handles downloading, uploading, and deleting attachments, as well as +/// periodically syncing attachment states. It ensures proper lifecycle management +/// of sync operations and provides mechanisms for error handling and retries. +/// +/// Properties: +/// - [remoteStorage]: The remote storage implementation for handling file operations. +/// - [localStorage]: The local storage implementation for managing files locally. +/// - [attachmentsService]: The service for managing attachment states and operations. +/// - [errorHandler]: Optional error handler for managing sync-related errors. +@internal +final class SyncingService { + final RemoteStorage remoteStorage; + final LocalStorage localStorage; + final AttachmentService attachmentsService; + final AttachmentErrorHandler? errorHandler; + final Duration syncThrottle; + final Duration period; + final Logger logger; + + StreamSubscription? _syncSubscription; + StreamSubscription? _periodicSubscription; + bool _isClosed = false; + final _syncTriggerController = StreamController.broadcast(); + + SyncingService({ + required this.remoteStorage, + required this.localStorage, + required this.attachmentsService, + this.errorHandler, + this.syncThrottle = const Duration(seconds: 5), + this.period = const Duration(seconds: 30), + required this.logger, + }); + + /// Starts the syncing process, including periodic and event-driven sync operations. + Future startSync() async { + if (_isClosed) return; + + _syncSubscription?.cancel(); + _periodicSubscription?.cancel(); + + // Create a merged stream of manual triggers and attachment changes + final attachmentChanges = attachmentsService.watchActiveAttachments( + throttle: syncThrottle, + ); + final manualTriggers = _syncTriggerController.stream; + + late StreamSubscription sub; + final syncStream = + StreamGroup.merge([attachmentChanges, manualTriggers]) + .takeWhile((_) => sub == _syncSubscription) + .asyncMap((_) async { + await attachmentsService.withContext((context) async { + final attachments = await context.getActiveAttachments(); + logger.info('Found ${attachments.length} active attachments'); + await handleSync(attachments, context); + await deleteArchivedAttachments(context); + }); + }); + + _syncSubscription = sub = syncStream.listen(null); + + // Start periodic sync using instance period + _periodicSubscription = Stream.periodic(period, (_) {}).listen(( + _, + ) { + logger.info('Periodically syncing attachments'); + triggerSync(); + }); + } + + /// Enqueues a sync operation (manual trigger). + void triggerSync() { + if (!_isClosed) _syncTriggerController.add(null); + } + + /// Stops all ongoing sync operations. + Future stopSync() async { + await _periodicSubscription?.cancel(); + + final subscription = _syncSubscription; + // Add a trigger event after clearing the subscription, which will make + // the takeWhile() callback cancel. This allows us to use asFuture() here, + // ensuring that we only complete this future when the stream is actually + // done. + _syncSubscription = null; + _syncTriggerController.add(null); + await subscription?.asFuture(); + } + + /// Closes the syncing service, stopping all operations and releasing resources. + Future close() async { + _isClosed = true; + await stopSync(); + await _syncTriggerController.close(); + } + + /// Handles syncing operations for a list of attachments, including downloading, + /// uploading, and deleting files based on their states. + /// + /// [attachments]: The list of attachments to process. + /// [context]: The attachment context used for managing attachment states. + Future handleSync( + List attachments, + AttachmentContext context, + ) async { + logger.info('Starting handleSync with ${attachments.length} attachments'); + final updatedAttachments = []; + + for (final attachment in attachments) { + logger.info( + 'Processing attachment ${attachment.id} with state: ${attachment.state}', + ); + try { + switch (attachment.state) { + case AttachmentState.queuedDownload: + logger.info('Downloading [${attachment.filename}]'); + updatedAttachments.add(await downloadAttachment(attachment)); + break; + case AttachmentState.queuedUpload: + logger.info('Uploading [${attachment.filename}]'); + updatedAttachments.add(await uploadAttachment(attachment)); + break; + case AttachmentState.queuedDelete: + logger.info('Deleting [${attachment.filename}]'); + updatedAttachments.add(await deleteAttachment(attachment, context)); + break; + case AttachmentState.synced: + logger.info('Attachment ${attachment.id} is already synced'); + break; + case AttachmentState.archived: + logger.info('Attachment ${attachment.id} is archived'); + break; + } + } catch (e, st) { + logger.warning('Error during sync for ${attachment.id}', e, st); + } + } + + if (updatedAttachments.isNotEmpty) { + logger.info('Saving ${updatedAttachments.length} updated attachments'); + await context.saveAttachments(updatedAttachments); + } + } + + /// Uploads an attachment from local storage to remote storage. + /// + /// [attachment]: The attachment to upload. + /// Returns the updated attachment with its new state. + Future uploadAttachment(Attachment attachment) async { + logger.info('Starting upload for attachment ${attachment.id}'); + try { + if (attachment.localUri == null) { + throw Exception('No localUri for attachment $attachment'); + } + await remoteStorage.uploadFile( + localStorage.readFile(attachment.localUri!), + attachment, + ); + logger.info( + 'Successfully uploaded attachment "${attachment.id}" to Cloud Storage', + ); + return attachment.copyWith( + state: AttachmentState.synced, + hasSynced: true, + ); + } catch (e, st) { + logger.warning( + 'Upload attachment error for attachment $attachment', + e, + st, + ); + if (errorHandler != null) { + final shouldRetry = + await errorHandler!.onUploadError(attachment, e, st); + if (!shouldRetry) { + logger.info('Attachment with ID ${attachment.id} has been archived'); + return attachment.copyWith(state: AttachmentState.archived); + } + } + return attachment; + } + } + + /// Downloads an attachment from remote storage and saves it to local storage. + /// + /// [attachment]: The attachment to download. + /// Returns the updated attachment with its new state. + Future downloadAttachment(Attachment attachment) async { + logger.info('Starting download for attachment ${attachment.id}'); + final attachmentPath = attachment.filename; + try { + final fileStream = await remoteStorage.downloadFile(attachment); + await localStorage.saveFile(attachmentPath, fileStream); + logger.info('Successfully downloaded file "${attachment.id}"'); + + return attachment.copyWith( + localUri: attachmentPath, + state: AttachmentState.synced, + hasSynced: true, + ); + } catch (e, st) { + if (errorHandler != null) { + final shouldRetry = + await errorHandler!.onDownloadError(attachment, e, st); + if (!shouldRetry) { + logger.info('Attachment with ID ${attachment.id} has been archived'); + return attachment.copyWith(state: AttachmentState.archived); + } + } + logger.warning( + 'Download attachment error for attachment $attachment', + e, + st, + ); + return attachment; + } + } + + /// Deletes an attachment from remote and local storage, and removes it from the queue. + /// + /// [attachment]: The attachment to delete. + /// Returns the updated attachment with its new state. + Future deleteAttachment( + Attachment attachment, AttachmentContext context) async { + try { + logger.info('Deleting attachment ${attachment.id} from remote storage'); + await remoteStorage.deleteFile(attachment); + + if (attachment.localUri != null && + await localStorage.fileExists(attachment.localUri!)) { + await localStorage.deleteFile(attachment.localUri!); + } + // Remove the attachment record from the queue in a transaction. + await context.deleteAttachment(attachment.id); + return attachment.copyWith(state: AttachmentState.archived); + } catch (e, st) { + if (errorHandler != null) { + final shouldRetry = + await errorHandler!.onDeleteError(attachment, e, st); + if (!shouldRetry) { + logger.info('Attachment with ID ${attachment.id} has been archived'); + return attachment.copyWith(state: AttachmentState.archived); + } + } + logger.warning('Error deleting attachment: $e', e, st); + return attachment; + } + } + + /// Deletes archived attachments from local storage. + /// + /// [context]: The attachment context used to retrieve and manage archived attachments. + /// Returns `true` if all archived attachments were successfully deleted, `false` otherwise. + Future deleteArchivedAttachments( + AttachmentContext context, + ) async { + return context.deleteArchivedAttachments((pendingDelete) async { + for (final attachment in pendingDelete) { + if (attachment.localUri == null) continue; + if (!await localStorage.fileExists(attachment.localUri!)) continue; + await localStorage.deleteFile(attachment.localUri!); + } + }); + } +} diff --git a/packages/powersync_core/lib/src/attachments/sync_error_handler.dart b/packages/powersync_core/lib/src/attachments/sync_error_handler.dart new file mode 100644 index 00000000..30aafcf4 --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/sync_error_handler.dart @@ -0,0 +1,102 @@ +import 'package:meta/meta.dart'; + +import 'attachment.dart'; + +/// The signature of a function handling an exception when uploading, +/// downloading or deleting an exception. +/// +/// It returns `true` if the operation should be retried. +/// +/// {@category attachments} +typedef AttachmentExceptionHandler = Future Function( + Attachment attachment, + Object exception, + StackTrace stackTrace, +); + +/// Interface for handling errors during attachment operations. +/// Implementations determine whether failed operations should be retried. +/// Attachment records are archived if an operation fails and should not be retried. +/// +/// {@category attachments} +@experimental +abstract interface class AttachmentErrorHandler { + /// Creates an implementation of an error handler by delegating to the + /// individual functions for delete, download and upload errors. + const factory AttachmentErrorHandler({ + required AttachmentExceptionHandler onDeleteError, + required AttachmentExceptionHandler onDownloadError, + required AttachmentExceptionHandler onUploadError, + }) = _FunctionBasedErrorHandler; + + /// Determines whether the provided attachment download operation should be retried. + /// + /// [attachment] The attachment involved in the failed download operation. + /// [exception] The exception that caused the download failure. + /// [stackTrace] The [StackTrace] when the exception was caught. + /// + /// Returns `true` if the download operation should be retried, `false` otherwise. + Future onDownloadError( + Attachment attachment, + Object exception, + StackTrace stackTrace, + ); + + /// Determines whether the provided attachment upload operation should be retried. + /// + /// [attachment] The attachment involved in the failed upload operation. + /// [exception] The exception that caused the upload failure. + /// [stackTrace] The [StackTrace] when the exception was caught. + /// + /// Returns `true` if the upload operation should be retried, `false` otherwise. + Future onUploadError( + Attachment attachment, + Object exception, + StackTrace stackTrace, + ); + + /// Determines whether the provided attachment delete operation should be retried. + /// + /// [attachment] The attachment involved in the failed delete operation. + /// [exception] The exception that caused the delete failure. + /// [stackTrace] The [StackTrace] when the exception was caught. + /// + /// Returns `true` if the delete operation should be retried, `false` otherwise. + Future onDeleteError( + Attachment attachment, + Object exception, + StackTrace stackTrace, + ); +} + +final class _FunctionBasedErrorHandler implements AttachmentErrorHandler { + final AttachmentExceptionHandler _onDeleteError; + final AttachmentExceptionHandler _onDownloadError; + final AttachmentExceptionHandler _onUploadError; + + const _FunctionBasedErrorHandler( + {required AttachmentExceptionHandler onDeleteError, + required AttachmentExceptionHandler onDownloadError, + required AttachmentExceptionHandler onUploadError}) + : _onDeleteError = onDeleteError, + _onDownloadError = onDownloadError, + _onUploadError = onUploadError; + + @override + Future onDeleteError( + Attachment attachment, Object exception, StackTrace stackTrace) { + return _onDeleteError(attachment, exception, stackTrace); + } + + @override + Future onDownloadError( + Attachment attachment, Object exception, StackTrace stackTrace) { + return _onDownloadError(attachment, exception, stackTrace); + } + + @override + Future onUploadError( + Attachment attachment, Object exception, StackTrace stackTrace) { + return _onUploadError(attachment, exception, stackTrace); + } +} diff --git a/packages/powersync_core/lib/src/database/core_version.dart b/packages/powersync_core/lib/src/database/core_version.dart index f0156078..1a3ca3da 100644 --- a/packages/powersync_core/lib/src/database/core_version.dart +++ b/packages/powersync_core/lib/src/database/core_version.dart @@ -57,10 +57,13 @@ extension type const PowerSyncCoreVersion((int, int, int) _tuple) { /// The minimum version of the sqlite core extensions we support. We check /// this version when opening databases to fail early and with an actionable /// error message. - // Note: When updating this, also update the download URL in - // scripts/init_powersync_core_binary.dart and the version ref in - // packages/sqlite3_wasm_build/build.sh - static const minimum = PowerSyncCoreVersion((0, 4, 2)); + // Note: When updating this, also update: + // + // - scripts/init_powersync_core_binary.dart + // - scripts/download_core_binary_demos.dart + // - packages/sqlite3_wasm_build/build.sh + // - Android and Darwin (CocoaPods and SwiftPM) in powersync_flutter_libs + static const minimum = PowerSyncCoreVersion((0, 4, 6)); /// The first version of the core extensions that this version of the Dart /// SDK doesn't support. diff --git a/packages/powersync_core/lib/src/database/native/native_powersync_database.dart b/packages/powersync_core/lib/src/database/native/native_powersync_database.dart index d55b69db..40489365 100644 --- a/packages/powersync_core/lib/src/database/native/native_powersync_database.dart +++ b/packages/powersync_core/lib/src/database/native/native_powersync_database.dart @@ -133,6 +133,8 @@ class PowerSyncDatabaseImpl Future connectInternal({ required PowerSyncBackendConnector connector, required ResolvedSyncOptions options, + required List initiallyActiveStreams, + required Stream> activeStreams, required AbortController abort, required Zone asyncWorkZone, }) async { @@ -140,6 +142,7 @@ class PowerSyncDatabaseImpl bool triedSpawningIsolate = false; StreamSubscription? crudUpdateSubscription; + StreamSubscription? activeStreamsSubscription; final receiveMessages = ReceivePort(); final receiveUnhandledErrors = ReceivePort(); final receiveExit = ReceivePort(); @@ -157,6 +160,7 @@ class PowerSyncDatabaseImpl // Cleanup crudUpdateSubscription?.cancel(); + activeStreamsSubscription?.cancel(); receiveMessages.close(); receiveUnhandledErrors.close(); receiveExit.close(); @@ -198,6 +202,10 @@ class PowerSyncDatabaseImpl crudUpdateSubscription = crudStream.listen((event) { port.send(['update']); }); + + activeStreamsSubscription = activeStreams.listen((streams) { + port.send(['changed_subscriptions', streams]); + }); } else if (action == 'uploadCrud') { await (data[1] as PortCompleter).handle(() async { await connector.uploadData(this); @@ -366,6 +374,9 @@ Future _syncIsolate(_PowerSyncDatabaseIsolateArgs args) async { } } else if (action == 'close') { await shutdown(); + } else if (action == 'changed_subscriptions') { + openedStreamingSync + ?.updateSubscriptions(message[1] as List); } } }); @@ -438,7 +449,7 @@ Future _syncIsolate(_PowerSyncDatabaseIsolateArgs args) async { } } - localUpdatesSubscription = db!.updates.listen((event) { + localUpdatesSubscription = db!.updatesSync.listen((event) { updatedTables.add(event.tableName); updateDebouncer ??= diff --git a/packages/powersync_core/lib/src/database/powersync_database_impl_stub.dart b/packages/powersync_core/lib/src/database/powersync_database_impl_stub.dart index a4f0b419..ae891cb7 100644 --- a/packages/powersync_core/lib/src/database/powersync_database_impl_stub.dart +++ b/packages/powersync_core/lib/src/database/powersync_database_impl_stub.dart @@ -7,6 +7,7 @@ import 'package:powersync_core/src/abort_controller.dart'; import 'package:powersync_core/src/database/powersync_db_mixin.dart'; import 'package:powersync_core/src/open_factory/abstract_powersync_open_factory.dart'; import '../sync/options.dart'; +import '../sync/streaming_sync.dart'; import 'powersync_database.dart'; import '../connector.dart'; @@ -115,6 +116,8 @@ class PowerSyncDatabaseImpl Future connectInternal({ required PowerSyncBackendConnector connector, required AbortController abort, + required List initiallyActiveStreams, + required Stream> activeStreams, required Zone asyncWorkZone, required ResolvedSyncOptions options, }) { diff --git a/packages/powersync_core/lib/src/database/powersync_db_mixin.dart b/packages/powersync_core/lib/src/database/powersync_db_mixin.dart index dc4b2ddb..fd722a2a 100644 --- a/packages/powersync_core/lib/src/database/powersync_db_mixin.dart +++ b/packages/powersync_core/lib/src/database/powersync_db_mixin.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:async/async.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:powersync_core/sqlite3_common.dart'; @@ -13,9 +14,13 @@ import 'package:powersync_core/src/powersync_update_notification.dart'; import 'package:powersync_core/src/schema.dart'; import 'package:powersync_core/src/schema_logic.dart'; import 'package:powersync_core/src/schema_logic.dart' as schema_logic; +import 'package:powersync_core/src/sync/connection_manager.dart'; import 'package:powersync_core/src/sync/options.dart'; import 'package:powersync_core/src/sync/sync_status.dart'; +import '../sync/stream.dart'; +import '../sync/streaming_sync.dart'; + mixin PowerSyncDatabaseMixin implements SqliteConnection { /// Schema used for the local database. Schema get schema; @@ -41,16 +46,13 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { @Deprecated("This field is unused, pass params to connect() instead") Map? clientParams; + late final ConnectionManager _connections; + /// Current connection status. - SyncStatus currentStatus = - const SyncStatus(connected: false, lastSyncedAt: null); + SyncStatus get currentStatus => _connections.currentStatus; /// Use this stream to subscribe to connection status updates. - late final Stream statusStream; - - @protected - StreamController statusStreamController = - StreamController.broadcast(); + Stream get statusStream => _connections.statusStream; late final ActiveDatabaseGroup _activeGroup; @@ -80,15 +82,6 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { @protected Future get isInitialized; - /// The abort controller for the current sync iteration. - /// - /// null when disconnected, present when connecting or connected. - /// - /// The controller must only be accessed from within a critical section of the - /// sync mutex. - @protected - AbortController? _abortActiveSync; - @protected Future baseInit() async { String identifier = 'memory'; @@ -106,15 +99,14 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { 'instantiation logic if this is not intentional', ); } - - statusStream = statusStreamController.stream; + _connections = ConnectionManager(this); updates = powerSyncUpdateNotifications(database.updates); await database.initialize(); await _checkVersion(); await database.execute('SELECT powersync_init()'); await updateSchema(schema); - await _updateHasSynced(); + await _connections.resolveOfflineSyncStatus(); } /// Check that a supported version of the powersync extension is loaded. @@ -140,55 +132,15 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { return isInitialized; } - Future _updateHasSynced() async { - // Query the database to see if any data has been synced. - final result = await database.getAll( - 'SELECT priority, last_synced_at FROM ps_sync_state ORDER BY priority;', - ); - const prioritySentinel = 2147483647; - var hasSynced = false; - DateTime? lastCompleteSync; - final priorityStatusEntries = []; - - DateTime parseDateTime(String sql) { - return DateTime.parse('${sql}Z').toLocal(); - } - - for (final row in result) { - final priority = row.columnAt(0) as int; - final lastSyncedAt = parseDateTime(row.columnAt(1) as String); - - if (priority == prioritySentinel) { - hasSynced = true; - lastCompleteSync = lastSyncedAt; - } else { - priorityStatusEntries.add(( - hasSynced: true, - lastSyncedAt: lastSyncedAt, - priority: BucketPriority(priority) - )); - } - } - - if (hasSynced != currentStatus.hasSynced) { - final status = SyncStatus( - hasSynced: hasSynced, - lastSyncedAt: lastCompleteSync, - priorityStatusEntries: priorityStatusEntries, - ); - setStatus(status); - } - } - /// Returns a [Future] which will resolve once at least one full sync cycle /// has completed (meaninng that the first consistent checkpoint has been /// reached across all buckets). /// /// When [priority] is null (the default), this method waits for the first - /// full sync checkpoint to complete. When set to a [BucketPriority] however, + /// full sync checkpoint to complete. When set to a [StreamPriority] however, /// it completes once all buckets within that priority (as well as those in /// higher priorities) have been synchronized at least once. - Future waitForFirstSync({BucketPriority? priority}) async { + Future waitForFirstSync({StreamPriority? priority}) async { bool matches(SyncStatus status) { if (priority == null) { return status.hasSynced == true; @@ -197,46 +149,13 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { } } - if (matches(currentStatus)) { - return; - } - await for (final result in statusStream) { - if (matches(result)) { - break; - } - } + return _connections.firstStatusMatching(matches); } @protected @visibleForTesting void setStatus(SyncStatus status) { - if (status != currentStatus) { - final newStatus = SyncStatus( - connected: status.connected, - downloading: status.downloading, - uploading: status.uploading, - connecting: status.connecting, - uploadError: status.uploadError, - downloadError: status.downloadError, - priorityStatusEntries: status.priorityStatusEntries, - downloadProgress: status.downloadProgress, - // Note that currently the streaming sync implementation will never set - // hasSynced. lastSyncedAt implies that syncing has completed at some - // point (hasSynced = true). - // The previous values of hasSynced should be preserved here. - lastSyncedAt: status.lastSyncedAt ?? currentStatus.lastSyncedAt, - hasSynced: status.lastSyncedAt != null - ? true - : status.hasSynced ?? currentStatus.hasSynced, - ); - - // If the absence of hasSynced was the only difference, the new states - // would be equal and don't require an event. So, check again. - if (newStatus != currentStatus) { - currentStatus = newStatus; - statusStreamController.add(currentStatus); - } - } + _connections.manuallyChangeSyncStatus(status); } @override @@ -261,9 +180,9 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { // Now we can close the database await database.close(); - // If there are paused subscriptionso n the status stream, don't delay + // If there are paused subscriptions on the status stream, don't delay // closing the database because of that. - unawaited(statusStreamController.close()); + _connections.close(); await _activeGroup.close(); } } @@ -297,67 +216,7 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { params: params, ); - if (schema.rawTables.isNotEmpty && - resolvedOptions.source.syncImplementation != - SyncClientImplementation.rust) { - throw UnsupportedError( - 'Raw tables are only supported by the Rust client.'); - } - - // ignore: deprecated_member_use_from_same_package - clientParams = params; - var thisConnectAborter = AbortController(); - final zone = Zone.current; - - late void Function() retryHandler; - - Future connectWithSyncLock() async { - // Ensure there has not been a subsequent connect() call installing a new - // sync client. - assert(identical(_abortActiveSync, thisConnectAborter)); - assert(!thisConnectAborter.aborted); - - await connectInternal( - connector: connector, - options: resolvedOptions, - abort: thisConnectAborter, - // Run follow-up async tasks in the parent zone, a new one is introduced - // while we hold the lock (and async tasks won't hold the sync lock). - asyncWorkZone: zone, - ); - - thisConnectAborter.onCompletion.whenComplete(retryHandler); - } - - // If the sync encounters a failure without being aborted, retry - retryHandler = Zone.current.bindCallback(() async { - _activeGroup.syncConnectMutex.lock(() async { - // Is this still supposed to be active? (abort is only called within - // mutex) - if (!thisConnectAborter.aborted) { - // We only change _abortActiveSync after disconnecting, which resets - // the abort controller. - assert(identical(_abortActiveSync, thisConnectAborter)); - - // We need a new abort controller for this attempt - _abortActiveSync = thisConnectAborter = AbortController(); - - logger.warning('Sync client failed, retrying...'); - await connectWithSyncLock(); - } - }); - }); - - await _activeGroup.syncConnectMutex.lock(() async { - // Disconnect a previous sync client, if one is active. - await _abortCurrentSync(); - assert(_abortActiveSync == null); - - // Install the abort controller for this particular connect call, allowing - // it to be disconnected. - _abortActiveSync = thisConnectAborter; - await connectWithSyncLock(); - }); + await _connections.connect(connector: connector, options: resolvedOptions); } /// Internal method to establish a sync client connection. @@ -371,6 +230,8 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { Future connectInternal({ required PowerSyncBackendConnector connector, required ResolvedSyncOptions options, + required List initiallyActiveStreams, + required Stream> activeStreams, required AbortController abort, required Zone asyncWorkZone, }); @@ -379,27 +240,7 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { /// /// Use [connect] to connect again. Future disconnect() async { - // Also wrap this in the sync mutex to ensure there's no race between us - // connecting and disconnecting. - await _activeGroup.syncConnectMutex.lock(_abortCurrentSync); - - setStatus( - SyncStatus(connected: false, lastSyncedAt: currentStatus.lastSyncedAt)); - } - - Future _abortCurrentSync() async { - if (_abortActiveSync case final disconnector?) { - /// Checking `disconnecter.aborted` prevents race conditions - /// where multiple calls to `disconnect` can attempt to abort - /// the controller more than once before it has finished aborting. - if (disconnector.aborted == false) { - await disconnector.abort(); - _abortActiveSync = null; - } else { - /// Wait for the abort to complete. Continue updating the sync status after completed - await disconnector.onCompletion; - } - } + await _connections.disconnect(); } /// Disconnect and clear the database. @@ -417,8 +258,7 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { await tx.execute('select powersync_clear(?)', [clearLocal ? 1 : 0]); }); // The data has been deleted - reset these - currentStatus = SyncStatus(lastSyncedAt: null, hasSynced: false); - statusStreamController.add(currentStatus); + setStatus(SyncStatus(lastSyncedAt: null, hasSynced: false)); } @Deprecated('Use [disconnectAndClear] instead.') @@ -440,9 +280,7 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { schema.validate(); await _activeGroup.syncConnectMutex.lock(() async { - if (_abortActiveSync != null) { - throw AssertionError('Cannot update schema while connected'); - } + _connections.checkNotConnected(); this.schema = schema; await database.writeLock((tx) => schema_logic.updateSchema(tx, schema)); @@ -508,23 +346,10 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { } final last = all[all.length - 1]; return CrudBatch( - crud: all, - haveMore: haveMore, - complete: ({String? writeCheckpoint}) async { - await writeTransaction((db) async { - await db - .execute('DELETE FROM ps_crud WHERE id <= ?', [last.clientId]); - if (writeCheckpoint != null && - await db.getOptional('SELECT 1 FROM ps_crud LIMIT 1') == null) { - await db.execute( - 'UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name=\'\$local\'', - [writeCheckpoint]); - } else { - await db.execute( - 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); - } - }); - }); + crud: all, + haveMore: haveMore, + complete: _crudCompletionCallback(last.clientId), + ); } /// Get the next recorded transaction to upload. @@ -538,46 +363,95 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { /// /// Unlike [getCrudBatch], this only returns data from a single transaction at a time. /// All data for the transaction is loaded into memory. - Future getNextCrudTransaction() async { - return await readTransaction((tx) async { - final first = await tx.getOptional( - 'SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT 1'); - if (first == null) { - return null; - } - final txId = first['tx_id'] as int?; - List all; - if (txId == null) { - all = [CrudEntry.fromRow(first)]; - } else { - final rows = await tx.getAll( - 'SELECT id, tx_id, data FROM ps_crud WHERE tx_id = ? ORDER BY id ASC', - [txId]); - all = [for (var row in rows) CrudEntry.fromRow(row)]; + Future getNextCrudTransaction() { + return getCrudTransactions().firstOrNull; + } + + /// Returns a stream of completed transactions with local writes against the + /// database. + /// + /// This is typically used from the [PowerSyncBackendConnector.uploadData] + /// method. Each entry emitted by the stream is a full transaction containing + /// all local writes made while that transaction was active. + /// + /// Unlike [getNextCrudTransaction], which always returns the oldest + /// transaction that hasn't been [CrudTransaction.complete]d yet, this stream + /// can be used to receive multiple transactions. Calling + /// [CrudTransaction.complete] will mark that transaction and all prior + /// transactions emitted by the stream as completed. + /// + /// This can be used to upload multiple transactions in a single batch, e.g. + /// with: + /// + /// ```dart + /// CrudTransaction? lastTransaction; + /// final batch = []; + /// + /// await for (final transaction in powersync.nextCrudTransactions()) { + /// batch.addAll(transaction.crud); + /// lastTransaction = transaction; + /// + /// if (batch.length > 100) { + /// break; + /// } + /// } + /// + /// if (batch.isNotEmpty) { + /// await uploadBatch(batch); + /// lastTransaction!.complete(); + /// } + /// ``` + /// + /// If there is no local data to upload, the stream emits a single `onDone` + /// event. + Stream getCrudTransactions() async* { + var lastCrudItemId = -1; + const sql = ''' +WITH RECURSIVE crud_entries AS ( + SELECT id, tx_id, data FROM ps_crud WHERE id = (SELECT min(id) FROM ps_crud WHERE id > ?) + UNION ALL + SELECT ps_crud.id, ps_crud.tx_id, ps_crud.data FROM ps_crud + INNER JOIN crud_entries ON crud_entries.id + 1 = rowid + WHERE crud_entries.tx_id = ps_crud.tx_id +) +SELECT * FROM crud_entries; +'''; + + while (true) { + final nextTransaction = await getAll(sql, [lastCrudItemId]); + if (nextTransaction.isEmpty) { + break; } - final last = all[all.length - 1]; - - return CrudTransaction( - transactionId: txId, - crud: all, - complete: ({String? writeCheckpoint}) async { - await writeTransaction((db) async { - await db.execute( - 'DELETE FROM ps_crud WHERE id <= ?', [last.clientId]); - if (writeCheckpoint != null && - await db.getOptional('SELECT 1 FROM ps_crud LIMIT 1') == - null) { - await db.execute( - 'UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name=\'\$local\'', - [writeCheckpoint]); - } else { - await db.execute( - 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); - } - }); - }); - }); + final items = [for (var row in nextTransaction) CrudEntry.fromRow(row)]; + final last = items.last; + final txId = last.transactionId; + + yield CrudTransaction( + crud: items, + complete: _crudCompletionCallback(last.clientId), + transactionId: txId, + ); + lastCrudItemId = last.clientId; + } + } + + Future Function({String? writeCheckpoint}) _crudCompletionCallback( + int lastClientId) { + return ({String? writeCheckpoint}) async { + await writeTransaction((db) async { + await db.execute('DELETE FROM ps_crud WHERE id <= ?', [lastClientId]); + if (writeCheckpoint != null && + await db.getOptional('SELECT 1 FROM ps_crud LIMIT 1') == null) { + await db.execute( + 'UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name=\'\$local\'', + [writeCheckpoint]); + } else { + await db.execute( + 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); + } + }); + }; } /// Takes a read lock, without starting a transaction. @@ -629,6 +503,13 @@ mixin PowerSyncDatabaseMixin implements SqliteConnection { Future refreshSchema() async { await database.refreshSchema(); } + + /// Create a [SyncStream] instance for the given [name] and [parameters]. + /// + /// Use [SyncStream.subscribe] to subscribe to the returned stream. + SyncStream syncStream(String name, [Map? parameters]) { + return _connections.syncStream(name, parameters); + } } Stream powerSyncUpdateNotifications( diff --git a/packages/powersync_core/lib/src/database/web/web_powersync_database.dart b/packages/powersync_core/lib/src/database/web/web_powersync_database.dart index 4af2821e..15a83c7d 100644 --- a/packages/powersync_core/lib/src/database/web/web_powersync_database.dart +++ b/packages/powersync_core/lib/src/database/web/web_powersync_database.dart @@ -128,6 +128,8 @@ class PowerSyncDatabaseImpl Future connectInternal({ required PowerSyncBackendConnector connector, required AbortController abort, + required List initiallyActiveStreams, + required Stream> activeStreams, required Zone asyncWorkZone, required ResolvedSyncOptions options, }) async { @@ -141,6 +143,7 @@ class PowerSyncDatabaseImpl connector: connector, options: options.source, workerUri: Uri.base.resolve('/powersync_sync.worker.js'), + subscriptions: initiallyActiveStreams, ); } catch (e) { logger.warning( @@ -157,6 +160,7 @@ class PowerSyncDatabaseImpl crudUpdateTriggerStream: crudStream, options: options, client: BrowserClient(), + activeSubscriptions: initiallyActiveStreams, // Only allows 1 sync implementation to run at a time per database // This should be global (across tabs) when using Navigator locks. identifier: database.openFactory.path, @@ -168,7 +172,10 @@ class PowerSyncDatabaseImpl }); sync.streamingSync(); + final subscriptions = activeStreams.listen(sync.updateSubscriptions); + abort.onAbort.then((_) async { + subscriptions.cancel(); await sync.abort(); abort.completeAbort(); }).ignore(); diff --git a/packages/powersync_core/lib/src/exceptions.dart b/packages/powersync_core/lib/src/exceptions.dart index e4f7c864..bc35df4a 100644 --- a/packages/powersync_core/lib/src/exceptions.dart +++ b/packages/powersync_core/lib/src/exceptions.dart @@ -61,7 +61,12 @@ class SyncResponseException implements Exception { static SyncResponseException _fromResponseBody( http.BaseResponse response, String body) { final decoded = convert.jsonDecode(body); - final details = _stringOrFirst(decoded['error']?['details']) ?? body; + final details = switch (decoded['error']) { + final Map details => _errorDescription(details), + _ => null, + } ?? + body; + final message = '${response.reasonPhrase ?? "Request failed"}: $details'; return SyncResponseException(response.statusCode, message); } @@ -73,6 +78,37 @@ class SyncResponseException implements Exception { ); } + /// Extracts an error description from an error resonse looking like + /// `{"code":"PSYNC_S2106","status":401,"description":"Authentication required","name":"AuthorizationError"}`. + static String? _errorDescription(Map raw) { + final code = raw['code']; // Required, string + final description = raw['description']; // Required, string + + final name = raw['name']; // Optional, string + final details = raw['details']; // Optional, string + + if (code is! String || description is! String) { + return null; + } + + final fullDescription = StringBuffer(code); + if (name is String) { + fullDescription.write('($name)'); + } + + fullDescription + ..write(': ') + ..write(description); + + if (details is String) { + fullDescription + ..write(', ') + ..write(details); + } + + return fullDescription.toString(); + } + int statusCode; String description; @@ -84,18 +120,6 @@ class SyncResponseException implements Exception { } } -String? _stringOrFirst(Object? details) { - if (details == null) { - return null; - } else if (details is String) { - return details; - } else if (details case [final String first, ...]) { - return first; - } else { - return null; - } -} - class PowersyncNotReadyException implements Exception { /// @nodoc PowersyncNotReadyException(this.message); diff --git a/packages/powersync_core/lib/src/open_factory/abstract_powersync_open_factory.dart b/packages/powersync_core/lib/src/open_factory/abstract_powersync_open_factory.dart index d5666fad..cb88af22 100644 --- a/packages/powersync_core/lib/src/open_factory/abstract_powersync_open_factory.dart +++ b/packages/powersync_core/lib/src/open_factory/abstract_powersync_open_factory.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'package:universal_io/io.dart'; import 'dart:math'; +import 'package:meta/meta.dart'; import 'package:powersync_core/sqlite_async.dart'; import 'package:powersync_core/src/open_factory/common_db_functions.dart'; import 'package:sqlite_async/sqlite3_common.dart'; @@ -70,6 +70,11 @@ abstract class AbstractPowerSyncOpenFactory extends DefaultSqliteOpenFactory { /// Returns the library name for the current platform. /// [path] is optional and is used when the library is not in the default location. String getLibraryForPlatform({String? path}); + + /// On native platforms, synchronously pauses the current isolate for the + /// given [Duration]. + @visibleForOverriding + void sleep(Duration duration) {} } /// Advanced: Define custom setup for each SQLite connection. diff --git a/packages/powersync_core/lib/src/open_factory/native/native_open_factory.dart b/packages/powersync_core/lib/src/open_factory/native/native_open_factory.dart index f564d349..775e59e8 100644 --- a/packages/powersync_core/lib/src/open_factory/native/native_open_factory.dart +++ b/packages/powersync_core/lib/src/open_factory/native/native_open_factory.dart @@ -1,8 +1,9 @@ +import 'dart:io'; +import 'dart:io' as io; import 'dart:ffi'; import 'package:powersync_core/src/exceptions.dart'; import 'package:powersync_core/src/log.dart'; -import 'package:universal_io/io.dart'; import 'dart:isolate'; import 'package:powersync_core/src/open_factory/abstract_powersync_open_factory.dart'; import 'package:sqlite_async/sqlite3.dart' as sqlite; @@ -109,4 +110,9 @@ class PowerSyncOpenFactory extends AbstractPowerSyncOpenFactory { ); } } + + @override + void sleep(Duration duration) { + io.sleep(duration); + } } diff --git a/packages/powersync_core/lib/src/open_factory/web/web_open_factory.dart b/packages/powersync_core/lib/src/open_factory/web/web_open_factory.dart index 6f5f156f..db099cb8 100644 --- a/packages/powersync_core/lib/src/open_factory/web/web_open_factory.dart +++ b/packages/powersync_core/lib/src/open_factory/web/web_open_factory.dart @@ -23,6 +23,7 @@ class PowerSyncOpenFactory extends AbstractPowerSyncOpenFactory wasmModule: Uri.parse(sqliteOptions.webSqliteOptions.wasmUri), worker: Uri.parse(sqliteOptions.webSqliteOptions.workerUri), controller: PowerSyncAsyncSqliteController(), + handleCustomRequest: handleCustomRequest, ); } diff --git a/packages/powersync_core/lib/src/sync/connection_manager.dart b/packages/powersync_core/lib/src/sync/connection_manager.dart new file mode 100644 index 00000000..8a326642 --- /dev/null +++ b/packages/powersync_core/lib/src/sync/connection_manager.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:meta/meta.dart'; +import 'package:powersync_core/src/abort_controller.dart'; +import 'package:powersync_core/src/connector.dart'; +import 'package:powersync_core/src/database/active_instances.dart'; +import 'package:powersync_core/src/database/powersync_db_mixin.dart'; +import 'package:powersync_core/src/sync/options.dart'; +import 'package:powersync_core/src/sync/stream.dart'; +import 'package:powersync_core/src/sync/sync_status.dart'; + +import 'instruction.dart'; +import 'mutable_sync_status.dart'; +import 'streaming_sync.dart'; + +/// A (stream name, JSON parameters) pair that uniquely identifies a stream +/// instantiation to subscribe to. +typedef _RawStreamKey = (String, String); + +@internal +final class ConnectionManager { + final PowerSyncDatabaseMixin db; + final ActiveDatabaseGroup _activeGroup; + + /// All streams (with parameters) for which a subscription has been requested + /// explicitly. + final Map<_RawStreamKey, _ActiveSubscription> _locallyActiveSubscriptions = + {}; + + final StreamController _statusController = + StreamController.broadcast(); + + /// Fires when an entry is added or removed from [_locallyActiveSubscriptions] + /// while we're connected. + StreamController? _subscriptionsChanged; + + SyncStatus _currentStatus = + const SyncStatus(connected: false, lastSyncedAt: null); + + SyncStatus get currentStatus => _currentStatus; + Stream get statusStream => _statusController.stream; + + /// The abort controller for the current sync iteration. + /// + /// null when disconnected, present when connecting or connected. + /// + /// The controller must only be accessed from within a critical section of the + /// sync mutex. + AbortController? _abortActiveSync; + + ConnectionManager(this.db) : _activeGroup = db.group; + + void checkNotConnected() { + if (_abortActiveSync != null) { + throw StateError('Cannot update schema while connected'); + } + } + + Future _abortCurrentSync() async { + if (_abortActiveSync case final disconnector?) { + /// Checking `disconnecter.aborted` prevents race conditions + /// where multiple calls to `disconnect` can attempt to abort + /// the controller more than once before it has finished aborting. + if (disconnector.aborted == false) { + await disconnector.abort(); + _abortActiveSync = null; + } else { + /// Wait for the abort to complete. Continue updating the sync status after completed + await disconnector.onCompletion; + } + } + } + + Future disconnect() async { + // Also wrap this in the sync mutex to ensure there's no race between us + // connecting and disconnecting. + await _activeGroup.syncConnectMutex.lock(() async { + await _abortCurrentSync(); + _subscriptionsChanged?.close(); + _subscriptionsChanged = null; + }); + + manuallyChangeSyncStatus( + SyncStatus(connected: false, lastSyncedAt: currentStatus.lastSyncedAt)); + } + + Future firstStatusMatching(bool Function(SyncStatus) predicate) async { + if (predicate(currentStatus)) { + return; + } + await for (final result in statusStream) { + if (predicate(result)) { + break; + } + } + } + + List get _subscribedStreams => [ + for (final active in _locallyActiveSubscriptions.values) + (name: active.name, parameters: active.encodedParameters) + ]; + + Future connect({ + required PowerSyncBackendConnector connector, + required ResolvedSyncOptions options, + }) async { + if (db.schema.rawTables.isNotEmpty && + options.source.syncImplementation != SyncClientImplementation.rust) { + throw UnsupportedError( + 'Raw tables are only supported by the Rust client.'); + } + + var thisConnectAborter = AbortController(); + final zone = Zone.current; + + late void Function() retryHandler; + + final subscriptionsChanged = StreamController(); + + Future connectWithSyncLock() async { + // Ensure there has not been a subsequent connect() call installing a new + // sync client. + assert(identical(_abortActiveSync, thisConnectAborter)); + assert(!thisConnectAborter.aborted); + + // ignore: invalid_use_of_protected_member + await db.connectInternal( + connector: connector, + options: options, + abort: thisConnectAborter, + initiallyActiveStreams: _subscribedStreams, + activeStreams: subscriptionsChanged.stream.map((_) { + return _subscribedStreams; + }), + // Run follow-up async tasks in the parent zone, a new one is introduced + // while we hold the lock (and async tasks won't hold the sync lock). + asyncWorkZone: zone, + ); + + thisConnectAborter.onCompletion.whenComplete(retryHandler); + } + + // If the sync encounters a failure without being aborted, retry + retryHandler = Zone.current.bindCallback(() async { + _activeGroup.syncConnectMutex.lock(() async { + // Is this still supposed to be active? (abort is only called within + // mutex) + if (!thisConnectAborter.aborted) { + // We only change _abortActiveSync after disconnecting, which resets + // the abort controller. + assert(identical(_abortActiveSync, thisConnectAborter)); + + // We need a new abort controller for this attempt + _abortActiveSync = thisConnectAborter = AbortController(); + + db.logger.warning('Sync client failed, retrying...'); + await connectWithSyncLock(); + } + }); + }); + + await _activeGroup.syncConnectMutex.lock(() async { + // Disconnect a previous sync client, if one is active. + await _abortCurrentSync(); + assert(_abortActiveSync == null); + _subscriptionsChanged = subscriptionsChanged; + + // Install the abort controller for this particular connect call, allowing + // it to be disconnected. + _abortActiveSync = thisConnectAborter; + await connectWithSyncLock(); + }); + } + + void manuallyChangeSyncStatus(SyncStatus status) { + if (status != currentStatus) { + final newStatus = SyncStatus( + connected: status.connected, + downloading: status.downloading, + uploading: status.uploading, + connecting: status.connecting, + uploadError: status.uploadError, + downloadError: status.downloadError, + priorityStatusEntries: status.priorityStatusEntries, + downloadProgress: status.downloadProgress, + // Note that currently the streaming sync implementation will never set + // hasSynced. lastSyncedAt implies that syncing has completed at some + // point (hasSynced = true). + // The previous values of hasSynced should be preserved here. + lastSyncedAt: status.lastSyncedAt ?? currentStatus.lastSyncedAt, + hasSynced: status.lastSyncedAt != null + ? true + : status.hasSynced ?? currentStatus.hasSynced, + streamSubscriptions: status.internalSubscriptions, + ); + + // If the absence of hasSynced was the only difference, the new states + // would be equal and don't require an event. So, check again. + if (newStatus != currentStatus) { + _currentStatus = newStatus; + _statusController.add(_currentStatus); + } + } + } + + _SyncStreamSubscriptionHandle _referenceStreamSubscription( + String stream, Map? parameters) { + final key = (stream, json.encode(parameters)); + _ActiveSubscription active; + + if (_locallyActiveSubscriptions[key] case final current?) { + active = current; + } else { + active = _ActiveSubscription(this, + name: stream, parameters: parameters, encodedParameters: key.$2); + _locallyActiveSubscriptions[key] = active; + _subscriptionsChanged?.add(null); + } + + return _SyncStreamSubscriptionHandle(active); + } + + void _clearSubscription(_ActiveSubscription subscription) { + assert(subscription.refcount == 0); + _locallyActiveSubscriptions + .remove((subscription.name, subscription.encodedParameters)); + _subscriptionsChanged?.add(null); + } + + Future _subscriptionsCommand(Object? command) async { + await db.writeTransaction((tx) { + return tx.execute( + 'SELECT powersync_control(?, ?)', + ['subscriptions', json.encode(command)], + ); + }); + _subscriptionsChanged?.add(null); + } + + Future subscribe({ + required String stream, + required Map? parameters, + Duration? ttl, + StreamPriority? priority, + }) async { + await _subscriptionsCommand({ + 'subscribe': { + 'stream': { + 'name': stream, + 'params': parameters, + }, + 'ttl': ttl?.inSeconds, + 'priority': priority, + }, + }); + + await _activeGroup.syncConnectMutex.lock(() async { + if (_abortActiveSync == null) { + // Since we're not connected, update the offline sync status to reflect + // the new subscription. + // With a connection, the sync client would include it in its state. + await resolveOfflineSyncStatus(); + } + }); + } + + Future unsubscribeAll({ + required String stream, + required Object? parameters, + }) async { + await _subscriptionsCommand({ + 'unsubscribe': { + 'name': stream, + 'params': parameters, + }, + }); + } + + Future resolveOfflineSyncStatus() async { + final row = await db.database.get( + 'SELECT powersync_offline_sync_status() AS r;', + ); + + final status = CoreSyncStatus.fromJson( + json.decode(row['r'] as String) as Map); + + manuallyChangeSyncStatus((MutableSyncStatus()..applyFromCore(status)) + .immutableSnapshot(setLastSynced: true)); + } + + SyncStream syncStream(String name, Map? parameters) { + return _SyncStreamImplementation(this, name, parameters); + } + + void close() { + _statusController.close(); + } +} + +final class _SyncStreamImplementation implements SyncStream { + @override + final String name; + + @override + final Map? parameters; + + final ConnectionManager _connections; + + _SyncStreamImplementation(this._connections, this.name, this.parameters); + + @override + Future subscribe({ + Duration? ttl, + StreamPriority? priority, + }) async { + await _connections.subscribe( + stream: name, + parameters: parameters, + ttl: ttl, + priority: priority, + ); + + return _connections._referenceStreamSubscription(name, parameters); + } + + @override + Future unsubscribeAll() async { + await _connections.unsubscribeAll(stream: name, parameters: parameters); + } +} + +final class _ActiveSubscription { + final ConnectionManager connections; + var refcount = 0; + + final String name; + final String encodedParameters; + final Map? parameters; + + _ActiveSubscription( + this.connections, { + required this.name, + required this.encodedParameters, + required this.parameters, + }); + + void decrementRefCount() { + refcount--; + if (refcount == 0) { + connections._clearSubscription(this); + } + } +} + +final class _SyncStreamSubscriptionHandle implements SyncStreamSubscription { + final _ActiveSubscription _source; + var _active = true; + + _SyncStreamSubscriptionHandle(this._source) { + _source.refcount++; + _finalizer.attach(this, _source, detach: this); + } + + @override + String get name => _source.name; + + @override + Map? get parameters => _source.parameters; + + @override + void unsubscribe() { + if (_active) { + _active = false; + _finalizer.detach(this); + _source.decrementRefCount(); + } + } + + @override + Future waitForFirstSync() async { + return _source.connections.firstStatusMatching((status) { + final currentProgress = status.forStream(this); + return currentProgress?.subscription.hasSynced ?? false; + }); + } + + static final Finalizer<_ActiveSubscription> _finalizer = Finalizer((sub) { + sub.connections.db.logger.warning( + 'A subscription to ${sub.name} (with parameters ${sub.parameters}) ' + 'leaked! Please ensure calling SyncStreamSubscription.unsubscribe() ' + "when you don't need a subscription anymore. For global " + 'subscriptions, consider storing them in global fields to avoid this ' + 'warning.'); + }); +} diff --git a/packages/powersync_core/lib/src/sync/instruction.dart b/packages/powersync_core/lib/src/sync/instruction.dart index f0146e8e..3479e281 100644 --- a/packages/powersync_core/lib/src/sync/instruction.dart +++ b/packages/powersync_core/lib/src/sync/instruction.dart @@ -1,3 +1,4 @@ +import 'stream.dart'; import 'sync_status.dart'; /// An internal instruction emitted by the sync client in the core extension in @@ -13,7 +14,8 @@ sealed class Instruction { EstablishSyncStream.fromJson(establish as Map), {'FetchCredentials': final creds} => FetchCredentials.fromJson(creds as Map), - {'CloseSyncStream': _} => const CloseSyncStream(), + {'CloseSyncStream': final closeOptions as Map} => + CloseSyncStream(closeOptions['hide_disconnect'] as bool), {'FlushFileSystem': _} => const FlushFileSystem(), {'DidCompleteSync': _} => const DidCompleteSync(), _ => UnknownSyncInstruction(json) @@ -62,12 +64,14 @@ final class CoreSyncStatus { final bool connecting; final List priorityStatus; final DownloadProgress? downloading; + final List? streams; CoreSyncStatus({ required this.connected, required this.connecting, required this.priorityStatus, required this.downloading, + required this.streams, }); factory CoreSyncStatus.fromJson(Map json) { @@ -82,12 +86,16 @@ final class CoreSyncStatus { null => null, final raw as Map => DownloadProgress.fromJson(raw), }, + streams: (json['streams'] as List) + .map((e) => + CoreActiveStreamSubscription.fromJson(e as Map)) + .toList(), ); } static SyncPriorityStatus _priorityStatusFromJson(Map json) { return ( - priority: BucketPriority(json['priority'] as int), + priority: StreamPriority(json['priority'] as int), hasSynced: json['has_synced'] as bool?, lastSyncedAt: switch (json['last_synced_at']) { null => null, @@ -116,7 +124,7 @@ final class DownloadProgress { static BucketProgress _bucketProgressFromJson(Map json) { return ( - priority: BucketPriority(json['priority'] as int), + priority: StreamPriority(json['priority'] as int), atLast: json['at_last'] as int, sinceLast: json['since_last'] as int, targetCount: json['target_count'] as int, @@ -135,7 +143,9 @@ final class FetchCredentials implements Instruction { } final class CloseSyncStream implements Instruction { - const CloseSyncStream(); + final bool hideDisconnect; + + const CloseSyncStream(this.hideDisconnect); } final class FlushFileSystem implements Instruction { diff --git a/packages/powersync_core/lib/src/sync/mutable_sync_status.dart b/packages/powersync_core/lib/src/sync/mutable_sync_status.dart index 23e3becb..273cd597 100644 --- a/packages/powersync_core/lib/src/sync/mutable_sync_status.dart +++ b/packages/powersync_core/lib/src/sync/mutable_sync_status.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'instruction.dart'; +import 'stream.dart'; import 'sync_status.dart'; import 'bucket_storage.dart'; import 'protocol.dart'; @@ -15,6 +16,7 @@ final class MutableSyncStatus { InternalSyncDownloadProgress? downloadProgress; List priorityStatusEntries = const []; + List? streams; DateTime? lastSyncedAt; @@ -51,9 +53,9 @@ final class MutableSyncStatus { hasSynced: true, lastSyncedAt: now, priority: maxBy( - applied.checksums.map((cs) => BucketPriority(cs.priority)), + applied.checksums.map((cs) => StreamPriority(cs.priority)), (priority) => priority, - compare: BucketPriority.comparator, + compare: StreamPriority.comparator, )!, ) ]; @@ -90,11 +92,12 @@ final class MutableSyncStatus { final downloading => InternalSyncDownloadProgress(downloading.buckets), }; lastSyncedAt = status.priorityStatus - .firstWhereOrNull((s) => s.priority == BucketPriority.fullSyncPriority) + .firstWhereOrNull((s) => s.priority == StreamPriority.fullSyncPriority) ?.lastSyncedAt; + streams = status.streams; } - SyncStatus immutableSnapshot() { + SyncStatus immutableSnapshot({bool setLastSynced = false}) { return SyncStatus( connected: connected, connecting: connecting, @@ -103,9 +106,10 @@ final class MutableSyncStatus { downloadProgress: downloadProgress?.asSyncDownloadProgress, priorityStatusEntries: UnmodifiableListView(priorityStatusEntries), lastSyncedAt: lastSyncedAt, - hasSynced: null, // Stream client is not supposed to set this value. + hasSynced: setLastSynced ? lastSyncedAt != null : null, uploadError: uploadError, downloadError: downloadError, + streamSubscriptions: streams, ); } } diff --git a/packages/powersync_core/lib/src/sync/options.dart b/packages/powersync_core/lib/src/sync/options.dart index 6ae94b25..ee8b3c63 100644 --- a/packages/powersync_core/lib/src/sync/options.dart +++ b/packages/powersync_core/lib/src/sync/options.dart @@ -27,11 +27,18 @@ final class SyncOptions { /// The [SyncClientImplementation] to use. final SyncClientImplementation syncImplementation; + /// Whether streams that have been defined with `auto_subscribe: true` should + /// be synced when they don't have an explicit subscription. + /// + /// This is enabled by default. + final bool? includeDefaultStreams; + const SyncOptions({ this.crudThrottleTime, this.retryDelay, this.params, this.syncImplementation = SyncClientImplementation.defaultClient, + this.includeDefaultStreams, }); SyncOptions _copyWith({ @@ -44,6 +51,7 @@ final class SyncOptions { retryDelay: retryDelay, params: params ?? this.params, syncImplementation: syncImplementation, + includeDefaultStreams: includeDefaultStreams, ); } } @@ -96,16 +104,23 @@ extension type ResolvedSyncOptions(SyncOptions source) { Map get params => source.params ?? const {}; + bool get includeDefaultStreams => source.includeDefaultStreams ?? true; + (ResolvedSyncOptions, bool) applyFrom(SyncOptions other) { final newOptions = SyncOptions( crudThrottleTime: other.crudThrottleTime ?? crudThrottleTime, retryDelay: other.retryDelay ?? retryDelay, params: other.params ?? params, + syncImplementation: other.syncImplementation, + includeDefaultStreams: + other.includeDefaultStreams ?? includeDefaultStreams, ); final didChange = !_mapEquality.equals(newOptions.params, params) || newOptions.crudThrottleTime != crudThrottleTime || - newOptions.retryDelay != retryDelay; + newOptions.retryDelay != retryDelay || + newOptions.syncImplementation != source.syncImplementation || + newOptions.includeDefaultStreams != includeDefaultStreams; return (ResolvedSyncOptions(newOptions), didChange); } diff --git a/packages/powersync_core/lib/src/sync/stream.dart b/packages/powersync_core/lib/src/sync/stream.dart new file mode 100644 index 00000000..80c447be --- /dev/null +++ b/packages/powersync_core/lib/src/sync/stream.dart @@ -0,0 +1,176 @@ +import 'package:meta/meta.dart'; + +import 'sync_status.dart'; +import '../database/powersync_database.dart'; + +/// A description of a sync stream, consisting of its [name] and the +/// [parameters] used when subscribing. +abstract interface class SyncStreamDescription { + /// The name of the stream as it appears in the stream definition for the + /// PowerSync service. + String get name; + + /// The parameters used to subscribe to the stream, if any. + /// + /// The same stream can be subscribed to multiple times with different + /// parameters. + Map? get parameters; +} + +/// Information about a subscribed sync stream. +/// +/// This includes the [SyncStreamDescription] along with information about the +/// current sync status. +abstract interface class SyncSubscriptionDescription + extends SyncStreamDescription { + /// Whether this stream is active, meaning that the subscription has been + /// acknowledged by the sync serivce. + bool get active; + + /// Whether this stream subscription is included by default, regardless of + /// whether the stream has explicitly been subscribed to or not. + /// + /// It's possible for both [isDefault] and [hasExplicitSubscription] to be + /// true at the same time - this happens when a default stream was subscribed + /// explicitly. + bool get isDefault; + + /// Whether this stream has been subscribed to explicitly. + /// + /// It's possible for both [isDefault] and [hasExplicitSubscription] to be + /// true at the same time - this happens when a default stream was subscribed + /// explicitly. + bool get hasExplicitSubscription; + + /// For sync streams that have a time-to-live, the current time at which the + /// stream would expire if not subscribed to again. + DateTime? get expiresAt; + + /// Whether this stream subscription has been synced at least once. + bool get hasSynced; + + /// If [hasSynced] is true, the last time data from this stream has been + /// synced. + DateTime? get lastSyncedAt; +} + +/// A handle to a [SyncStreamDescription] that allows subscribing to the stream. +/// +/// To obtain an instance of [SyncStream], call [PowerSyncDatabase.syncStream]. +abstract interface class SyncStream extends SyncStreamDescription { + /// Adds a subscription to this stream, requesting it to be included when + /// connecting to the sync service. + /// + /// The [priority] can be used to override the priority of this stream. + Future subscribe({ + Duration? ttl, + StreamPriority? priority, + }); + + Future unsubscribeAll(); +} + +/// A [SyncStream] that has been subscribed to. +abstract interface class SyncStreamSubscription + implements SyncStreamDescription { + /// A variant of [PowerSyncDatabase.waitForFirstSync] that is specific to + /// this stream subscription. + Future waitForFirstSync(); + + /// Removes this subscription. + /// + /// Once all [SyncStreamSubscription]s for a [SyncStream] have been + /// unsubscribed, the `ttl` for that stream starts running. When it expires + /// without subscribing again, the stream will be evicted. + void unsubscribe(); +} + +/// An `ActiveStreamSubscription` as part of the sync status in Rust. +@internal +final class CoreActiveStreamSubscription + implements SyncSubscriptionDescription { + @override + final String name; + @override + final Map? parameters; + final StreamPriority priority; + final ({int total, int downloaded}) progress; + @override + final bool active; + @override + final bool isDefault; + @override + final bool hasExplicitSubscription; + @override + final DateTime? expiresAt; + @override + final DateTime? lastSyncedAt; + + @override + bool get hasSynced => lastSyncedAt != null; + + CoreActiveStreamSubscription._({ + required this.name, + required this.parameters, + required this.priority, + required this.progress, + required this.active, + required this.isDefault, + required this.hasExplicitSubscription, + required this.expiresAt, + required this.lastSyncedAt, + }); + + factory CoreActiveStreamSubscription.fromJson(Map json) { + return CoreActiveStreamSubscription._( + name: json['name'] as String, + parameters: json['parameters'] as Map?, + priority: switch (json['priority'] as int?) { + final prio? => StreamPriority(prio), + null => StreamPriority.fullSyncPriority, + }, + progress: _progressFromJson(json['progress'] as Map), + active: json['active'] as bool, + isDefault: json['is_default'] as bool, + hasExplicitSubscription: json['has_explicit_subscription'] as bool, + expiresAt: switch (json['expires_at']) { + null => null, + final timestamp as int => + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + }, + lastSyncedAt: switch (json['last_synced_at']) { + null => null, + final timestamp as int => + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000), + }, + ); + } + + Map toJson() { + return { + 'name': name, + 'parameters': parameters, + 'priority': priority.priorityNumber, + 'progress': { + 'total': progress.total, + 'downloaded': progress.downloaded, + }, + 'active': active, + 'is_default': isDefault, + 'has_explicit_subscription': hasExplicitSubscription, + 'expires_at': switch (expiresAt) { + null => null, + final expiresAt => expiresAt.millisecondsSinceEpoch / 1000, + }, + 'last_synced_at': switch (lastSyncedAt) { + null => null, + final lastSyncedAt => lastSyncedAt.millisecondsSinceEpoch / 1000, + } + }; + } + + static ({int total, int downloaded}) _progressFromJson( + Map json) { + return (total: json['total'] as int, downloaded: json['downloaded'] as int); + } +} diff --git a/packages/powersync_core/lib/src/sync/stream_utils.dart b/packages/powersync_core/lib/src/sync/stream_utils.dart index a4689cf9..d531e783 100644 --- a/packages/powersync_core/lib/src/sync/stream_utils.dart +++ b/packages/powersync_core/lib/src/sync/stream_utils.dart @@ -1,6 +1,12 @@ import 'dart:async'; import 'dart:convert' as convert; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:typed_data/typed_buffers.dart'; + +import '../exceptions.dart'; /// Inject a broadcast stream into a normal stream. Stream addBroadcast(Stream a, Stream broadcast) { @@ -75,6 +81,11 @@ extension ByteStreamToLines on Stream> { final textInput = transform(convert.utf8.decoder); return textInput.transform(const convert.LineSplitter()); } + + /// Splits this stream into BSON documents without parsing them. + Stream get bsonDocuments { + return Stream.eventTransformed(this, _BsonSplittingSink.new); + } } extension StreamToJson on Stream { @@ -99,3 +110,143 @@ Future cancelAll(List> subscriptions) async { final futures = subscriptions.map((sub) => sub.cancel()); await Future.wait(futures); } + +/// A variant of [Stream.fromFuture] that makes [StreamSubscription.cancel] +/// await the original future and report errors. +/// +/// When using the regular [Stream.fromFuture], cancelling the subscription +/// before the future completes with an error could cause an handled error to +/// be reported. +/// Further, it could cause concurrency issues in the stream client because it +/// was possible for us to: +/// +/// 1. Make an HTTP request, wrapped in [Stream.fromFuture] to later expand +/// sync lines from the response. +/// 2. Receive a cancellation request, posting an event on another stream that +/// is merged into the pending HTTP stream. +/// 3. Act on the cancellation request by cancelling the merged subscription. +/// 4. As a clean-up action, close the HTTP client (while the request is still +/// being resolved). +/// +/// Running step 4 and 1 concurrently is really bad, so we delay the +/// cancellation until step 1 has completed. +/// +/// As a further discussion, note that throwing in [StreamSubscription.cancel] +/// is also not exactly a good practice. However, it is the only way to properly +/// delay cancelling streams here, since the [future] itself is a critical +/// operation that must complete first here. +Stream streamFromFutureAwaitInCancellation(Future future) { + final controller = StreamController(sync: true); + var cancelled = false; + + final handledFuture = future.then((value) { + controller + ..add(value) + ..close(); + }, onError: (Object error, StackTrace trace) { + if (cancelled) { + // Make handledFuture complete with the error, so that controller.cancel + // throws (instead of the error being unhandled). + throw error; + } else { + controller + ..addError(error, trace) + ..close(); + } + }); + + controller.onCancel = () async { + cancelled = true; + await handledFuture; + }; + + return controller.stream; +} + +/// An [EventSink] that takes raw bytes as inputs, buffers them internally by +/// reading a 4-byte length prefix for each message and then emits them as +/// chunks. +final class _BsonSplittingSink implements EventSink> { + final EventSink _downstream; + + final length = ByteData(4); + int remainingBytes = 4; + + Uint8Buffer? pendingBuffer; + + _BsonSplittingSink(this._downstream); + + @override + void add(List data) { + var i = 0; + while (i < data.length) { + final availableInData = data.length - i; + + if (pendingBuffer case final pending?) { + // We're in the middle of reading a document + final bytesToRead = min(availableInData, remainingBytes); + pending.addAll(data, i, i + bytesToRead); + i += bytesToRead; + remainingBytes -= bytesToRead; + assert(remainingBytes >= 0); + + if (remainingBytes == 0) { + _downstream.add(pending.buffer + .asUint8List(pending.offsetInBytes, pending.lengthInBytes)); + + // Prepare reading another document, starting with its length + pendingBuffer = null; + remainingBytes = 4; + } + } else { + final bytesToRead = min(availableInData, remainingBytes); + final lengthAsUint8List = length.buffer.asUint8List(); + + lengthAsUint8List.setRange( + 4 - remainingBytes, + 4 - remainingBytes + bytesToRead, + data, + i, + ); + i += bytesToRead; + remainingBytes -= bytesToRead; + assert(remainingBytes >= 0); + + if (remainingBytes == 0) { + // Transition from reading length header to reading document. + // Subtracting 4 because the length of the header is included in the + // length. + remainingBytes = length.getInt32(0, Endian.little) - 4; + if (remainingBytes < 5) { + _downstream.addError( + PowerSyncProtocolException( + 'Invalid length for bson: $remainingBytes'), + StackTrace.current, + ); + } + + pendingBuffer = Uint8Buffer()..addAll(lengthAsUint8List); + } + } + } + + assert(i == data.length); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _downstream.addError(error, stackTrace); + } + + @override + void close() { + if (pendingBuffer != null || remainingBytes != 4) { + _downstream.addError( + PowerSyncProtocolException('Pending data when stream was closed'), + StackTrace.current, + ); + } + + _downstream.close(); + } +} diff --git a/packages/powersync_core/lib/src/sync/streaming_sync.dart b/packages/powersync_core/lib/src/sync/streaming_sync.dart index bbdcb85a..60deef12 100644 --- a/packages/powersync_core/lib/src/sync/streaming_sync.dart +++ b/packages/powersync_core/lib/src/sync/streaming_sync.dart @@ -21,6 +21,8 @@ import 'stream_utils.dart'; import 'sync_status.dart'; import 'protocol.dart'; +typedef SubscribedStream = ({String name, String parameters}); + abstract interface class StreamingSync { Stream get statusStream; @@ -28,6 +30,8 @@ abstract interface class StreamingSync { /// Close any active streams. Future abort(); + + void updateSubscriptions(List streams); } @internal @@ -36,6 +40,7 @@ class StreamingSyncImplementation implements StreamingSync { final BucketStorage adapter; final InternalConnector connector; final ResolvedSyncOptions options; + List _activeSubscriptions; final Logger logger; @@ -69,6 +74,7 @@ class StreamingSyncImplementation implements StreamingSync { required this.crudUpdateTriggerStream, required this.options, required http.Client client, + List activeSubscriptions = const [], Mutex? syncMutex, Mutex? crudMutex, Logger? logger, @@ -80,7 +86,8 @@ class StreamingSyncImplementation implements StreamingSync { syncMutex = syncMutex ?? Mutex(identifier: "sync-$identifier"), crudMutex = crudMutex ?? Mutex(identifier: "crud-$identifier"), _userAgentHeaders = userAgentHeaders(), - logger = logger ?? isolateLogger; + logger = logger ?? isolateLogger, + _activeSubscriptions = activeSubscriptions; Duration get _retryDelay => options.retryDelay; @@ -124,6 +131,14 @@ class StreamingSyncImplementation implements StreamingSync { return _abort?.aborted ?? false; } + @override + void updateSubscriptions(List streams) { + _activeSubscriptions = streams; + if (_nonLineSyncEvents.hasListener) { + _nonLineSyncEvents.add(HandleChangedSubscriptions(streams)); + } + } + @override Future streamingSync() async { try { @@ -294,7 +309,12 @@ class StreamingSyncImplementation implements StreamingSync { } Future _rustStreamingSyncIteration() async { - await _ActiveRustStreamingIteration(this).syncIteration(); + logger.info('Starting Rust sync iteration'); + final response = await _ActiveRustStreamingIteration(this).syncIteration(); + logger.info( + 'Ending Rust sync iteration. Immediate restart: ${response.immediateRestart}'); + // Note: With the current loop in streamingSync(), any return value that + // isn't an exception triggers an immediate restart. } Future<(List, Map)> @@ -370,7 +390,7 @@ class StreamingSyncImplementation implements StreamingSync { // checkpoint later. } else { _updateStatusForPriority(( - priority: BucketPriority(bucketPriority), + priority: StreamPriority(bucketPriority), lastSyncedAt: DateTime.now(), hasSynced: true, )); @@ -449,6 +469,7 @@ class StreamingSyncImplementation implements StreamingSync { _state.updateStatus((s) => s.setConnected()); await handleLine(line as StreamingSyncLine); case UploadCompleted(): + case HandleChangedSubscriptions(): // Only relevant for the Rust sync implementation. break; case AbortCurrentIteration(): @@ -506,16 +527,24 @@ class StreamingSyncImplementation implements StreamingSync { } } - Future _postStreamRequest(Object? data) async { + Future _postStreamRequest( + Object? data, bool acceptBson, + {Future? onAbort}) async { + const ndJson = 'application/x-ndjson'; + const bson = 'application/vnd.powersync.bson-stream'; + final credentials = await connector.getCredentialsCached(); if (credentials == null) { throw CredentialsException('Not logged in'); } final uri = credentials.endpointUri('sync/stream'); - final request = http.Request('POST', uri); + final request = http.AbortableRequest('POST', uri, + abortTrigger: onAbort ?? _abort!.onAbort); request.headers['Content-Type'] = 'application/json'; request.headers['Authorization'] = "Token ${credentials.token}"; + request.headers['Accept'] = + acceptBson ? '$bson;q=0.9,$ndJson;q=0.8' : ndJson; request.headers.addAll(_userAgentHeaders); request.body = convert.jsonEncode(data); @@ -535,18 +564,13 @@ class StreamingSyncImplementation implements StreamingSync { return res; } - Stream _rawStreamingSyncRequest(Object? data) async* { - final response = await _postStreamRequest(data); - if (response != null) { - yield* response.stream.lines; - } - } - Stream _streamingSyncRequest(StreamingSyncRequest data) { - return _rawStreamingSyncRequest(data) - .parseJson - .cast>() - .transform(StreamingSyncLine.reader); + return streamFromFutureAwaitInCancellation(_postStreamRequest(data, false)) + .asyncExpand((response) { + return response?.stream.lines.parseJson + .cast>() + .transform(StreamingSyncLine.reader); + }); } /// Delays the standard `retryDelay` Duration, but exits early if @@ -587,25 +611,35 @@ typedef BucketDescription = ({ final class _ActiveRustStreamingIteration { final StreamingSyncImplementation sync; + var _isActive = true; var _hadSyncLine = false; StreamSubscription? _completedUploads; - final Completer _completedStream = Completer(); + final Completer _completedStream = Completer(); _ActiveRustStreamingIteration(this.sync); - Future syncIteration() async { + List _encodeSubscriptions(List subscriptions) { + return sync._activeSubscriptions + .map((s) => + {'name': s.name, 'params': convert.json.decode(s.parameters)}) + .toList(); + } + + Future syncIteration() async { try { await _control( 'start', convert.json.encode({ 'parameters': sync.options.params, 'schema': convert.json.decode(sync.schemaJson), + 'include_defaults': sync.options.includeDefaultStreams, + 'active_streams': _encodeSubscriptions(sync._activeSubscriptions), }), ); assert(_completedStream.isCompleted, 'Should have started streaming'); - await _completedStream.future; + return await _completedStream.future; } finally { _isActive = false; _completedUploads?.cancel(); @@ -613,35 +647,88 @@ final class _ActiveRustStreamingIteration { } } - Stream _receiveLines(Object? data) { - return sync._rawStreamingSyncRequest(data).map(ReceivedLine.new); + Stream _receiveLines(Object? data, + {required Future onAbort}) { + return streamFromFutureAwaitInCancellation( + sync._postStreamRequest(data, true, onAbort: onAbort)) + .asyncExpand((response) { + if (response == null) { + return null; + } else { + final contentType = response.headers['content-type']; + final isBson = contentType == 'application/vnd.powersync.bson-stream'; + + return isBson ? response.stream.bsonDocuments : response.stream.lines; + } + }).map(ReceivedLine.new); } - Future _handleLines(EstablishSyncStream request) async { + Future _handleLines( + EstablishSyncStream request) async { + // This is a workaround for https://github.com/dart-lang/http/issues/1820: + // When cancelling the stream subscription of an HTTP response with the + // fetch-based client implementation, cancelling the subscription is delayed + // until the next chunk (typically a token_expires_in message in our case). + // So, before cancelling, we complete an abort controller for the request to + // speed things up. This is not an issue in most cases because the abort + // controller on this stream would be completed when disconnecting. But + // when switching sync streams, that's not the case and we need a second + // abort controller for the inner iteration. + final innerAbort = Completer.sync(); final events = addBroadcast( - _receiveLines(request.request), sync._nonLineSyncEvents.stream); - + _receiveLines( + request.request, + onAbort: Future.any([ + sync._abort!.onAbort, + innerAbort.future, + ]), + ), + sync._nonLineSyncEvents.stream, + ); + + var needsImmediateRestart = false; loop: - await for (final event in events) { - if (!_isActive || sync.aborted) { - break; - } + try { + await for (final event in events) { + if (!_isActive || sync.aborted) { + innerAbort.complete(); + break; + } - switch (event) { - case ReceivedLine(line: final Uint8List line): - _triggerCrudUploadOnFirstLine(); - await _control('line_binary', line); - case ReceivedLine(line: final line as String): - _triggerCrudUploadOnFirstLine(); - await _control('line_text', line); - case UploadCompleted(): - await _control('completed_upload'); - case AbortCurrentIteration(): - break loop; - case TokenRefreshComplete(): - await _control('refreshed_token'); + switch (event) { + case ReceivedLine(line: final Uint8List line): + _triggerCrudUploadOnFirstLine(); + await _control('line_binary', line); + case ReceivedLine(line: final line as String): + _triggerCrudUploadOnFirstLine(); + await _control('line_text', line); + case UploadCompleted(): + await _control('completed_upload'); + case AbortCurrentIteration(:final hideDisconnectState): + innerAbort.complete(); + needsImmediateRestart = hideDisconnectState; + break loop; + case TokenRefreshComplete(): + await _control('refreshed_token'); + case HandleChangedSubscriptions(:final currentSubscriptions): + await _control( + 'update_subscriptions', + convert.json + .encode(_encodeSubscriptions(currentSubscriptions))); + } + } + } on http.RequestAbortedException { + // Unlike a regular cancellation, cancelling via the abort controller + // emits an error. We did mean to just cancel the stream, so we can + // safely ignore that. + if (innerAbort.isCompleted) { + // ignore + } else { + rethrow; } } + + return (immediateRestart: needsImmediateRestart); } /// Triggers a local CRUD upload when the first sync line has been received. @@ -695,10 +782,11 @@ final class _ActiveRustStreamingIteration { sync.logger.warning('Could not prefetch credentials', e, s); }); } - case CloseSyncStream(): + case CloseSyncStream(:final hideDisconnect): if (!sync.aborted) { _isActive = false; - sync._nonLineSyncEvents.add(const AbortCurrentIteration()); + sync._nonLineSyncEvents + .add(AbortCurrentIteration(hideDisconnectState: hideDisconnect)); } case FlushFileSystem(): await sync.adapter.flushFileSystem(); @@ -710,6 +798,8 @@ final class _ActiveRustStreamingIteration { } } +typedef RustSyncIterationResult = ({bool immediateRestart}); + sealed class SyncEvent {} final class ReceivedLine implements SyncEvent { @@ -727,5 +817,18 @@ final class TokenRefreshComplete implements SyncEvent { } final class AbortCurrentIteration implements SyncEvent { - const AbortCurrentIteration(); + /// Whether we should immediately disconnect and hide the `disconnected` + /// state. + /// + /// This is used when we're changing subscription, to hide the brief downtime + /// we have while reconnecting. + final bool hideDisconnectState; + + const AbortCurrentIteration({this.hideDisconnectState = false}); +} + +final class HandleChangedSubscriptions implements SyncEvent { + final List currentSubscriptions; + + HandleChangedSubscriptions(this.currentSubscriptions); } diff --git a/packages/powersync_core/lib/src/sync/sync_status.dart b/packages/powersync_core/lib/src/sync/sync_status.dart index 62c48df1..61ae7c5f 100644 --- a/packages/powersync_core/lib/src/sync/sync_status.dart +++ b/packages/powersync_core/lib/src/sync/sync_status.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'bucket_storage.dart'; import 'protocol.dart'; +import 'stream.dart'; final class SyncStatus { /// true if currently connected. @@ -54,6 +55,9 @@ final class SyncStatus { final List priorityStatusEntries; + final List? _internalSubscriptions; + + @internal const SyncStatus({ this.connected = false, this.connecting = false, @@ -65,7 +69,8 @@ final class SyncStatus { this.downloadError, this.uploadError, this.priorityStatusEntries = const [], - }); + List? streamSubscriptions, + }) : _internalSubscriptions = streamSubscriptions; @override bool operator ==(Object other) { @@ -78,8 +83,10 @@ final class SyncStatus { other.uploadError == uploadError && other.lastSyncedAt == lastSyncedAt && other.hasSynced == hasSynced && - _statusEquality.equals( + _listEquality.equals( other.priorityStatusEntries, priorityStatusEntries) && + _listEquality.equals( + other._internalSubscriptions, _internalSubscriptions) && other.downloadProgress == downloadProgress); } @@ -110,6 +117,16 @@ final class SyncStatus { ); } + /// All sync streams currently being tracked in the database. + /// + /// This returns null when the database is currently being opened and we + /// don't have reliable information about all included streams yet. + Iterable? get syncStreams { + return _internalSubscriptions?.map((subscription) { + return SyncStreamStatus._(subscription, downloadProgress); + }); + } + /// Get the current [downloadError] or [uploadError]. Object? get anyError { return downloadError ?? uploadError; @@ -128,9 +145,9 @@ final class SyncStatus { /// information extracted from the lower priority `2` since each partial sync /// in priority `2` necessarily includes a consistent view over data in /// priority `1`. - SyncPriorityStatus statusForPriority(BucketPriority priority) { + SyncPriorityStatus statusForPriority(StreamPriority priority) { assert(priorityStatusEntries.isSortedByCompare( - (e) => e.priority, BucketPriority.comparator)); + (e) => e.priority, StreamPriority.comparator)); for (final known in priorityStatusEntries) { // Lower-priority buckets are synchronized after higher-priority buckets, @@ -149,6 +166,21 @@ final class SyncStatus { ); } + /// If the [stream] appears in [syncStreams], returns the current status for + /// that stream. + SyncStreamStatus? forStream(SyncStreamDescription stream) { + final raw = _internalSubscriptions?.firstWhereOrNull( + (e) => + e.name == stream.name && + _mapEquality.equals(e.parameters, stream.parameters), + ); + + if (raw == null) { + return null; + } + return SyncStreamStatus._(raw, downloadProgress); + } + @override int get hashCode { return Object.hash( @@ -159,8 +191,9 @@ final class SyncStatus { uploadError, downloadError, lastSyncedAt, - _statusEquality.hash(priorityStatusEntries), + _listEquality.hash(priorityStatusEntries), downloadProgress, + _listEquality.hash(_internalSubscriptions), ); } @@ -169,37 +202,66 @@ final class SyncStatus { return "SyncStatus"; } - // This should be a ListEquality, but that appears to - // cause weird type errors with DDC (but only after hot reloads?!) - static const _statusEquality = ListEquality(); + static const _listEquality = ListEquality(); + static const _mapEquality = MapEquality(); +} + +@internal +extension InternalSyncStatusAccess on SyncStatus { + List? get internalSubscriptions => + _internalSubscriptions; +} + +/// Current information about a [SyncStream] that the sync client is subscribed +/// to. +final class SyncStreamStatus { + /// If the [SyncStatus] is currently [SyncStatus.downloading], download + /// progress for this stream. + final ProgressWithOperations? progress; + final CoreActiveStreamSubscription _internal; + + /// The [SyncSubscriptionDescription] providing information about the current + /// stream state. + SyncSubscriptionDescription get subscription => _internal; + + /// The [StreamPriority] of the current stream. + /// + /// New data on higher-priority streams can interrupt lower-priority streams. + StreamPriority get priority => _internal.priority; + + SyncStreamStatus._(this._internal, SyncDownloadProgress? progress) + : progress = progress?._internal._forStream(_internal); } -/// The priority of a PowerSync bucket. -extension type const BucketPriority._(int priorityNumber) { +@Deprecated('Use StreamPriority instead') +typedef BucketPriority = StreamPriority; + +/// The priority of a PowerSync stream. +extension type const StreamPriority._(int priorityNumber) { static const _highest = 0; - factory BucketPriority(int i) { + factory StreamPriority(int i) { assert(i >= _highest); - return BucketPriority._(i); + return StreamPriority._(i); } - bool operator >(BucketPriority other) => comparator(this, other) > 0; - bool operator >=(BucketPriority other) => comparator(this, other) >= 0; - bool operator <(BucketPriority other) => comparator(this, other) < 0; - bool operator <=(BucketPriority other) => comparator(this, other) <= 0; + bool operator >(StreamPriority other) => comparator(this, other) > 0; + bool operator >=(StreamPriority other) => comparator(this, other) >= 0; + bool operator <(StreamPriority other) => comparator(this, other) < 0; + bool operator <=(StreamPriority other) => comparator(this, other) <= 0; - /// A [Comparator] instance suitable for comparing [BucketPriority] values. - static int comparator(BucketPriority a, BucketPriority b) => + /// A [Comparator] instance suitable for comparing [StreamPriority] values. + static int comparator(StreamPriority a, StreamPriority b) => -a.priorityNumber.compareTo(b.priorityNumber); /// The priority used by PowerSync to indicate that a full sync was completed. - static const fullSyncPriority = BucketPriority._(2147483647); + static const fullSyncPriority = StreamPriority._(2147483647); } /// Partial information about the synchronization status for buckets within a /// priority. typedef SyncPriorityStatus = ({ - BucketPriority priority, + StreamPriority priority, DateTime? lastSyncedAt, bool? hasSynced, }); @@ -227,7 +289,7 @@ class UploadQueueStats { /// Per-bucket download progress information. @internal typedef BucketProgress = ({ - BucketPriority priority, + StreamPriority priority, int atLast, int sinceLast, int targetCount, @@ -253,7 +315,7 @@ final class InternalSyncDownloadProgress extends ProgressWithOperations { final sinceLast = savedProgress?.sinceLast ?? 0; buckets[bucket.bucket] = ( - priority: BucketPriority._(bucket.priority), + priority: StreamPriority._(bucket.priority), atLast: atLast, sinceLast: sinceLast, targetCount: bucket.count ?? 0, @@ -268,7 +330,7 @@ final class InternalSyncDownloadProgress extends ProgressWithOperations { return InternalSyncDownloadProgress({ for (final bucket in target.checksums) bucket.bucket: ( - priority: BucketPriority(bucket.priority), + priority: StreamPriority(bucket.priority), atLast: 0, sinceLast: 0, targetCount: knownCount, @@ -287,17 +349,16 @@ final class InternalSyncDownloadProgress extends ProgressWithOperations { /// Sums the total target and completed operations for all buckets up until /// the given [priority] (inclusive). - ProgressWithOperations untilPriority(BucketPriority priority) { - final (total, downloaded) = - buckets.values.where((e) => e.priority >= priority).fold( - (0, 0), - (prev, entry) { - final downloaded = entry.sinceLast; - final total = entry.targetCount - entry.atLast; - return (prev.$1 + total, prev.$2 + downloaded); - }, - ); + ProgressWithOperations untilPriority(StreamPriority priority) { + final (total, downloaded) = buckets.values + .where((e) => e.priority >= priority) + .fold((0, 0), _addProgress); + + return ProgressWithOperations._(total, downloaded); + } + ProgressWithOperations _forStream(CoreActiveStreamSubscription subscription) { + final (:total, :downloaded) = subscription.progress; return ProgressWithOperations._(total, downloaded); } @@ -340,6 +401,12 @@ final class InternalSyncDownloadProgress extends ProgressWithOperations { } static const _mapEquality = MapEquality(); + + (int, int) _addProgress((int, int) prev, BucketProgress entry) { + final downloaded = entry.sinceLast; + final total = entry.targetCount - entry.atLast; + return (prev.$1 + total, prev.$2 + downloaded); + } } /// Information about a progressing download. @@ -403,7 +470,7 @@ extension type SyncDownloadProgress._(InternalSyncDownloadProgress _internal) /// The returned [ProgressWithOperations] tracks the target amount of /// operations that need to be downloaded in total and how many of them have /// already been received. - ProgressWithOperations untilPriority(BucketPriority priority) { + ProgressWithOperations untilPriority(StreamPriority priority) { return _internal.untilPriority(priority); } } diff --git a/packages/powersync_core/lib/src/version.dart b/packages/powersync_core/lib/src/version.dart index e57575ab..211139eb 100644 --- a/packages/powersync_core/lib/src/version.dart +++ b/packages/powersync_core/lib/src/version.dart @@ -1 +1 @@ -const String libraryVersion = '1.5.0'; +const String libraryVersion = '1.6.1'; diff --git a/packages/powersync_core/lib/src/web/sync_controller.dart b/packages/powersync_core/lib/src/web/sync_controller.dart index 7f05cff3..b3f0ef18 100644 --- a/packages/powersync_core/lib/src/web/sync_controller.dart +++ b/packages/powersync_core/lib/src/web/sync_controller.dart @@ -15,6 +15,7 @@ class SyncWorkerHandle implements StreamingSync { final PowerSyncBackendConnector connector; final SyncOptions options; late final WorkerCommunicationChannel _channel; + List subscriptions; final StreamController _status = StreamController.broadcast(); @@ -24,6 +25,7 @@ class SyncWorkerHandle implements StreamingSync { required this.options, required MessagePort sendToWorker, required SharedWorker worker, + required this.subscriptions, }) { _channel = WorkerCommunicationChannel( port: sendToWorker, @@ -81,6 +83,7 @@ class SyncWorkerHandle implements StreamingSync { required PowerSyncBackendConnector connector, required Uri workerUri, required SyncOptions options, + required List subscriptions, }) async { final worker = SharedWorker(workerUri.toString().toJS); final handle = SyncWorkerHandle._( @@ -89,6 +92,7 @@ class SyncWorkerHandle implements StreamingSync { connector: connector, sendToWorker: worker.port, worker: worker, + subscriptions: subscriptions, ); // Make sure that the worker is working, or throw immediately. @@ -116,6 +120,13 @@ class SyncWorkerHandle implements StreamingSync { database.database.openFactory.path, ResolvedSyncOptions(options), database.schema, + subscriptions, ); } + + @override + void updateSubscriptions(List streams) { + subscriptions = streams; + _channel.updateSubscriptions(streams); + } } diff --git a/packages/powersync_core/lib/src/web/sync_worker.dart b/packages/powersync_core/lib/src/web/sync_worker.dart index ddc4eaf0..1c92808f 100644 --- a/packages/powersync_core/lib/src/web/sync_worker.dart +++ b/packages/powersync_core/lib/src/web/sync_worker.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'dart:js_interop'; import 'package:async/async.dart'; +import 'package:collection/collection.dart'; import 'package:http/browser_client.dart'; import 'package:logging/logging.dart'; import 'package:powersync_core/powersync_core.dart'; @@ -45,8 +46,12 @@ class _SyncWorker { }); } - _SyncRunner referenceSyncTask(String databaseIdentifier, SyncOptions options, - String schemaJson, _ConnectedClient client) { + _SyncRunner referenceSyncTask( + String databaseIdentifier, + SyncOptions options, + String schemaJson, + List subscriptions, + _ConnectedClient client) { return _requestedSyncTasks.putIfAbsent(databaseIdentifier, () { return _SyncRunner(databaseIdentifier); }) @@ -54,6 +59,7 @@ class _SyncWorker { client, options, schemaJson, + subscriptions, ); } } @@ -90,13 +96,22 @@ class _ConnectedClient { }, ); - _runner = _worker.referenceSyncTask(request.databaseName, - recoveredOptions, request.schemaJson, this); + _runner = _worker.referenceSyncTask( + request.databaseName, + recoveredOptions, + request.schemaJson, + request.subscriptions?.toDart ?? const [], + this, + ); return (JSObject(), null); case SyncWorkerMessageType.abortSynchronization: _runner?.disconnectClient(this); _runner = null; return (JSObject(), null); + case SyncWorkerMessageType.updateSubscriptions: + _runner?.updateClientSubscriptions( + this, (payload as UpdateSubscriptions).toDart); + return (JSObject(), null); default: throw StateError('Unexpected message type $type'); } @@ -137,9 +152,10 @@ class _SyncRunner { final StreamGroup<_RunnerEvent> _group = StreamGroup(); final StreamController<_RunnerEvent> _mainEvents = StreamController(); - StreamingSync? sync; + StreamingSyncImplementation? sync; _ConnectedClient? databaseHost; - final connections = <_ConnectedClient>[]; + final connections = <_ConnectedClient, List>{}; + List currentStreams = []; _SyncRunner(this.identifier) { _group.add(_mainEvents.stream); @@ -152,8 +168,9 @@ class _SyncRunner { :final client, :final options, :final schemaJson, + :final subscriptions, ): - connections.add(client); + connections[client] = subscriptions; final (newOptions, reconnect) = this.options.applyFrom(options); this.options = newOptions; this.schemaJson = schemaJson; @@ -165,6 +182,8 @@ class _SyncRunner { sync?.abort(); sync = null; await _requestDatabase(client); + } else { + reindexSubscriptions(); } case _RemoveConnection(:final client): connections.remove(client); @@ -191,6 +210,12 @@ class _SyncRunner { } else { await _requestDatabase(newHost); } + case _ClientSubscriptionsChanged( + :final client, + :final subscriptions + ): + connections[client] = subscriptions; + reindexSubscriptions(); } } catch (e, s) { _logger.warning('Error handling $event', e, s); @@ -199,12 +224,24 @@ class _SyncRunner { }); } + /// Updates [currentStreams] to the union of values in [connections]. + void reindexSubscriptions() { + final before = currentStreams.toSet(); + final after = connections.values.flattenedToSet; + if (!const SetEquality().equals(before, after)) { + _logger.info( + 'Subscriptions across tabs have changed, checking whether a reconnect is necessary'); + currentStreams = after.toList(); + sync?.updateSubscriptions(currentStreams); + } + } + /// Pings all current [connections], removing those that don't answer in 5s /// (as they are likely closed tabs as well). /// /// Returns the first client that responds (without waiting for others). Future<_ConnectedClient?> _collectActiveClients() async { - final candidates = connections.toList(); + final candidates = connections.keys.toList(); if (candidates.isEmpty) { return null; } @@ -269,6 +306,7 @@ class _SyncRunner { ); } + currentStreams = connections.values.flattenedToSet.toList(); sync = StreamingSyncImplementation( adapter: WebBucketStorage(database), schemaJson: client._runner!.schemaJson, @@ -283,10 +321,12 @@ class _SyncRunner { options: options, client: BrowserClient(), identifier: identifier, + activeSubscriptions: currentStreams, + logger: _logger, ); sync!.statusStream.listen((event) { _logger.fine('Broadcasting sync event: $event'); - for (final client in connections) { + for (final client in connections.keys) { client.channel.notify(SyncWorkerMessageType.notifySyncStatus, SerializedSyncStatus.from(event)); } @@ -294,9 +334,9 @@ class _SyncRunner { sync!.streamingSync(); } - void registerClient( - _ConnectedClient client, SyncOptions options, String schemaJson) { - _mainEvents.add(_AddConnection(client, options, schemaJson)); + void registerClient(_ConnectedClient client, SyncOptions options, + String schemaJson, List subscriptions) { + _mainEvents.add(_AddConnection(client, options, schemaJson, subscriptions)); } /// Remove a client, disconnecting if no clients remain.. @@ -308,6 +348,11 @@ class _SyncRunner { void disconnectClient(_ConnectedClient client) { _mainEvents.add(_DisconnectClient(client)); } + + void updateClientSubscriptions( + _ConnectedClient client, List subscriptions) { + _mainEvents.add(_ClientSubscriptionsChanged(client, subscriptions)); + } } sealed class _RunnerEvent {} @@ -316,8 +361,10 @@ final class _AddConnection implements _RunnerEvent { final _ConnectedClient client; final SyncOptions options; final String schemaJson; + final List subscriptions; - _AddConnection(this.client, this.options, this.schemaJson); + _AddConnection( + this.client, this.options, this.schemaJson, this.subscriptions); } final class _RemoveConnection implements _RunnerEvent { @@ -332,6 +379,13 @@ final class _DisconnectClient implements _RunnerEvent { _DisconnectClient(this.client); } +final class _ClientSubscriptionsChanged implements _RunnerEvent { + final _ConnectedClient client; + final List subscriptions; + + _ClientSubscriptionsChanged(this.client, this.subscriptions); +} + final class _ActiveDatabaseClosed implements _RunnerEvent { const _ActiveDatabaseClosed(); } diff --git a/packages/powersync_core/lib/src/web/sync_worker_protocol.dart b/packages/powersync_core/lib/src/web/sync_worker_protocol.dart index 3c64d90f..0448fe5a 100644 --- a/packages/powersync_core/lib/src/web/sync_worker_protocol.dart +++ b/packages/powersync_core/lib/src/web/sync_worker_protocol.dart @@ -5,10 +5,12 @@ import 'dart:js_interop'; import 'package:logging/logging.dart'; import 'package:powersync_core/src/schema.dart'; import 'package:powersync_core/src/sync/options.dart'; +import 'package:powersync_core/src/sync/stream.dart'; import 'package:web/web.dart'; import '../connector.dart'; import '../log.dart'; +import '../sync/streaming_sync.dart'; import '../sync/sync_status.dart'; /// Names used in [SyncWorkerMessage] @@ -20,6 +22,9 @@ enum SyncWorkerMessageType { /// If parameters change, the sync worker reconnects. startSynchronization, + /// Update the active subscriptions that this client is interested in. + updateSubscriptions, + /// The [SyncWorkerMessage.payload] for the request is a numeric id, the /// response can be anything (void). /// This disconnects immediately, even if other clients are still open. @@ -74,6 +79,7 @@ extension type StartSynchronization._(JSObject _) implements JSObject { required String implementationName, required String schemaJson, String? syncParamsEncoded, + UpdateSubscriptions? subscriptions, }); external String get databaseName; @@ -83,6 +89,36 @@ extension type StartSynchronization._(JSObject _) implements JSObject { external String? get implementationName; external String get schemaJson; external String? get syncParamsEncoded; + external UpdateSubscriptions? get subscriptions; +} + +@anonymous +extension type UpdateSubscriptions._raw(JSObject _inner) implements JSObject { + external factory UpdateSubscriptions._({ + required int requestId, + required JSArray content, + }); + + factory UpdateSubscriptions(int requestId, List streams) { + return UpdateSubscriptions._( + requestId: requestId, + content: streams + .map((e) => [e.name.toJS, e.parameters.toJS].toJS) + .toList() + .toJS, + ); + } + + external int get requestId; + external JSArray get content; + + List get toDart { + return content.toDart.map((e) { + final [name, parameters] = (e as JSArray).toDart; + + return (name: name.toDart, parameters: parameters.toDart); + }).toList(); + } } @anonymous @@ -190,7 +226,7 @@ extension type SerializedBucketProgress._(JSObject _) implements JSObject { return { for (final entry in array.toDart) entry.name: ( - priority: BucketPriority(entry.priority), + priority: StreamPriority(entry.priority), atLast: entry.atLast, sinceLast: entry.sinceLast, targetCount: entry.targetCount, @@ -212,6 +248,7 @@ extension type SerializedSyncStatus._(JSObject _) implements JSObject { required String? downloadError, required JSArray? priorityStatusEntries, required JSArray? syncProgress, + required JSString streamSubscriptions, }); factory SerializedSyncStatus.from(SyncStatus status) { @@ -237,6 +274,7 @@ extension type SerializedSyncStatus._(JSObject _) implements JSObject { var other => SerializedBucketProgress.serialize( InternalSyncDownloadProgress.ofPublic(other).buckets), }, + streamSubscriptions: json.encode(status.internalSubscriptions).toJS, ); } @@ -250,8 +288,11 @@ extension type SerializedSyncStatus._(JSObject _) implements JSObject { external String? downloadError; external JSArray? priorityStatusEntries; external JSArray? syncProgress; + external JSString? streamSubscriptions; SyncStatus asSyncStatus() { + final streamSubscriptions = this.streamSubscriptions?.toDart; + return SyncStatus( connected: connected, connecting: connecting, @@ -271,7 +312,7 @@ extension type SerializedSyncStatus._(JSObject _) implements JSObject { final syncedMillis = (rawSynced as JSNumber?)?.toDartInt; return ( - priority: BucketPriority((rawPriority as JSNumber).toDartInt), + priority: StreamPriority((rawPriority as JSNumber).toDartInt), lastSyncedAt: syncedMillis != null ? DateTime.fromMicrosecondsSinceEpoch(syncedMillis) : null, @@ -285,6 +326,13 @@ extension type SerializedSyncStatus._(JSObject _) implements JSObject { SerializedBucketProgress.deserialize(serializedProgress)) .asSyncDownloadProgress, }, + streamSubscriptions: switch (streamSubscriptions) { + null => null, + final serialized => (json.decode(serialized) as List?) + ?.map((e) => CoreActiveStreamSubscription.fromJson( + e as Map)) + .toList(), + }, ); } } @@ -339,6 +387,8 @@ final class WorkerCommunicationChannel { return; case SyncWorkerMessageType.startSynchronization: requestId = (message.payload as StartSynchronization).requestId; + case SyncWorkerMessageType.updateSubscriptions: + requestId = (message.payload as UpdateSubscriptions).requestId; case SyncWorkerMessageType.requestEndpoint: case SyncWorkerMessageType.abortSynchronization: case SyncWorkerMessageType.credentialsCallback: @@ -413,7 +463,11 @@ final class WorkerCommunicationChannel { } Future startSynchronization( - String databaseName, ResolvedSyncOptions options, Schema schema) async { + String databaseName, + ResolvedSyncOptions options, + Schema schema, + List streams, + ) async { final (id, completion) = _newRequest(); port.postMessage(SyncWorkerMessage( type: SyncWorkerMessageType.startSynchronization.name, @@ -428,11 +482,22 @@ final class WorkerCommunicationChannel { null => null, final params => jsonEncode(params), }, + subscriptions: UpdateSubscriptions(-1, streams), ), )); await completion; } + Future updateSubscriptions(List streams) async { + final (id, completion) = _newRequest(); + port.postMessage(SyncWorkerMessage( + type: SyncWorkerMessageType.updateSubscriptions.name, + payload: UpdateSubscriptions(id, streams), + )); + + await completion; + } + Future abortSynchronization() async { await _numericRequest(SyncWorkerMessageType.abortSynchronization); } diff --git a/packages/powersync_core/pubspec.yaml b/packages/powersync_core/pubspec.yaml index f82d1c9a..723cd4e9 100644 --- a/packages/powersync_core/pubspec.yaml +++ b/packages/powersync_core/pubspec.yaml @@ -1,5 +1,5 @@ name: powersync_core -version: 1.5.0 +version: 1.6.1 homepage: https://powersync.com repository: https://github.com/powersync-ja/powersync.dart description: PowerSync Dart SDK - sync engine for building local-first apps. @@ -8,19 +8,18 @@ environment: sdk: ^3.4.3 dependencies: - sqlite_async: ^0.11.4 + sqlite_async: ^0.12.1 # We only use sqlite3 as a transitive dependency, # but right now we need a minimum of v2.4.6. sqlite3: ^2.4.6 # We implement a database controller, which is an interface of sqlite3_web. - sqlite3_web: ^0.3.1 - universal_io: ^2.0.0 + sqlite3_web: ^0.3.2 meta: ^1.0.0 - http: ^1.4.0 + http: ^1.5.0 uuid: ^4.2.0 async: ^2.10.0 logging: ^1.1.1 - collection: ^1.17.0 + collection: ^1.19.0 web: ^1.0.0 # Only used internally to download WASM / worker files. @@ -28,6 +27,7 @@ dependencies: pub_semver: ^2.0.0 pubspec_parse: ^1.3.0 path: ^1.8.0 + typed_data: ^1.4.0 dev_dependencies: lints: ^5.1.1 @@ -38,6 +38,9 @@ dev_dependencies: shelf_static: ^1.1.2 stream_channel: ^2.1.2 fake_async: ^1.3.3 + bson: ^5.0.7 + test_descriptor: ^2.0.2 + mockito: ^5.5.0 platforms: android: diff --git a/packages/powersync_core/test/attachments/attachment_test.dart b/packages/powersync_core/test/attachments/attachment_test.dart new file mode 100644 index 00000000..90cd1f37 --- /dev/null +++ b/packages/powersync_core/test/attachments/attachment_test.dart @@ -0,0 +1,372 @@ +import 'dart:typed_data'; + +import 'package:async/async.dart'; +import 'package:logging/logging.dart'; +import 'package:mockito/mockito.dart'; +import 'package:powersync_core/attachments/attachments.dart'; +import 'package:powersync_core/powersync_core.dart'; +import 'package:test/test.dart'; + +import '../utils/abstract_test_utils.dart'; +import '../utils/test_utils_impl.dart'; + +void main() { + late TestPowerSyncFactory factory; + late PowerSyncDatabase db; + late MockRemoteStorage remoteStorage; + late LocalStorage localStorage; + late AttachmentQueue queue; + late StreamQueue> attachments; + + Stream> watchAttachments() { + return db + .watch('SELECT photo_id FROM users WHERE photo_id IS NOT NULL') + .map( + (rs) => [ + for (final row in rs) + WatchedAttachmentItem( + id: row['photo_id'] as String, fileExtension: 'jpg') + ], + ); + } + + setUpAll(() async { + factory = await TestUtils().testFactory(); + }); + + setUp(() async { + remoteStorage = MockRemoteStorage(); + localStorage = LocalStorage.inMemory(); + + final (raw, database) = await factory.openInMemoryDatabase( + schema: _schema, + // Uncomment to see test logs + logger: Logger.detached('PowerSyncTest'), + ); + await database.initialize(); + db = database; + + queue = AttachmentQueue( + db: db, + remoteStorage: remoteStorage, + watchAttachments: watchAttachments, + localStorage: localStorage, + archivedCacheLimit: 0, + ); + + attachments = StreamQueue(db.attachments); + await expectLater(attachments, emits(isEmpty)); + }); + + tearDown(() async { + await attachments.cancel(); + await queue.stopSyncing(); + await queue.close(); + + await db.close(); + }); + + test('downloads attachments', () async { + await queue.startSync(); + + // Create a user with a photo_id specified. Since we didn't save an + // attachment before assigning a photo_id, this is equivalent to reuqiring + // an attachment download. + await db.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, uuid())', + ['steven', 'steven@journeyapps.com'], + ); + + var [attachment] = await attachments.next; + if (attachment.state == AttachmentState.queuedDownload) { + // Depending on timing with the queue scanning for items asynchronously, + // we may see a queued download or a synced event initially. + [attachment] = await attachments.next; + } + + expect(attachment.state, AttachmentState.synced); + final localUri = attachment.localUri!; + + // A download should he been attempted for this file. + verify(remoteStorage.downloadFile(argThat(isAttachment(attachment.id)))); + + // A file should now exist. + expect(await localStorage.fileExists(localUri), isTrue); + + // Now clear the user's photo_id, which should archive the attachment. + await db.execute('UPDATE users SET photo_id = NULL'); + + var nextAttachment = (await attachments.next).firstOrNull; + if (nextAttachment != null) { + expect(nextAttachment.state, AttachmentState.archived); + nextAttachment = (await attachments.next).firstOrNull; + } + + expect(nextAttachment, isNull); + + // File should have been deleted too + expect(await localStorage.fileExists(localUri), isFalse); + }); + + test('stores relative paths', () async { + // Regression test we had in the Kotlin/Swift implementation: + // https://github.com/powersync-ja/powersync-swift/pull/74 + await queue.startSync(); + await db.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, ?)', + ['steven', 'steven@journeyapps.com', 'picture_id'], + ); + + // Wait for attachment to sync. + await expectLater( + attachments, + emitsThrough([ + isA() + .having((e) => e.state, 'state', AttachmentState.synced) + ])); + + expect(await localStorage.fileExists('picture_id.jpg'), isTrue); + }); + + test('recovers from deleted local files', () async { + // Create an attachments record which has an invalid local_uri. + await db.execute( + 'INSERT OR REPLACE INTO attachments_queue ' + '(id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data) ' + 'VALUES (uuid(), current_timestamp, ?, ?, ?, ?, ?, ?, ?)', + [ + 'attachment.jpg', + 'invalid/dir/attachment.jpg', + 'application/jpeg', + 1, + AttachmentState.synced.toInt(), + 1, + "" + ], + ); + await attachments.next; + + queue = AttachmentQueue( + db: db, + remoteStorage: remoteStorage, + watchAttachments: watchAttachments, + localStorage: localStorage, + archivedCacheLimit: 1, + ); + + // The attachment should be marked as archived, and the local URI should be + // removed. + await queue.startSync(); + + final [attachment] = await attachments.next; + expect(attachment.filename, 'attachment.jpg'); + expect(attachment.localUri, isNull); + expect(attachment.state, AttachmentState.archived); + }); + + test('uploads attachments', () async { + await queue.startSync(); + + final record = await queue.saveFile( + data: Stream.value(Uint8List(123)), + mediaType: 'image/jpg', + updateHook: (tx, attachment) async { + await tx.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, ?);', + ['steven', 'steven@journeyapps.com', attachment.id], + ); + }, + ); + expect(record.size, 123); + + var [attachment] = await attachments.next; + if (attachment.state == AttachmentState.queuedUpload) { + // Wait for it to be synced + [attachment] = await attachments.next; + } + + expect(attachment.state, AttachmentState.synced); + + // An upload should have been attempted for this file. + verify(remoteStorage.uploadFile(any, argThat(isAttachment(record.id)))); + expect(await localStorage.fileExists(record.localUri!), isTrue); + + // Now clear the user's photo_id, which should archive the attachment. + await db.execute('UPDATE users SET photo_id = NULL'); + + // Should delete attachment from database + await expectLater(attachments, emitsThrough(isEmpty)); + + // File should have been deleted too + expect(await localStorage.fileExists(record.localUri!), isFalse); + }); + + test('delete attachments', () async { + await queue.startSync(); + + final id = await queue.generateAttachmentId(); + await db.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, ?)', + ['steven', 'steven@journeyapps.com', id], + ); + + // Wait for the attachment to be synced. + await expectLater( + attachments, + emitsThrough([ + isA() + .having((e) => e.state, 'state', AttachmentState.synced) + ]), + ); + + await queue.deleteFile( + attachmentId: id, + updateHook: (tx, attachment) async { + await tx.execute( + 'UPDATE users SET photo_id = NULL WHERE photo_id = ?', + [attachment.id], + ); + }, + ); + + // Record should be deleted. + await expectLater(attachments, emitsThrough(isEmpty)); + verify(remoteStorage.deleteFile(argThat(isAttachment(id)))); + }); + + test('cached download', () async { + queue = AttachmentQueue( + db: db, + remoteStorage: remoteStorage, + watchAttachments: watchAttachments, + localStorage: localStorage, + archivedCacheLimit: 10, + ); + + await queue.startSync(); + + // Create attachment and wait for download. + await db.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, uuid())', + ['steven', 'steven@journeyapps.com'], + ); + await expectLater( + attachments, + emitsThrough([ + isA() + .having((e) => e.state, 'state', AttachmentState.synced) + ]), + ); + final [id as String, localUri as String] = + (await db.get('SELECT id, local_uri FROM attachments_queue')).values; + verify(remoteStorage.downloadFile(argThat(isAttachment(id)))); + expect(await localStorage.fileExists(localUri), isTrue); + + // Archive attachment by not referencing it anymore. + await db.execute('UPDATE users SET photo_id = NULL'); + await expectLater( + attachments, + emitsThrough([ + isA() + .having((e) => e.state, 'state', AttachmentState.archived) + ]), + ); + + // Restore from cache + await db.execute('UPDATE users SET photo_id = ?', [id]); + await expectLater( + attachments, + emitsThrough([ + isA() + .having((e) => e.state, 'state', AttachmentState.synced) + ]), + ); + expect(await localStorage.fileExists(localUri), isTrue); + + // Should not have downloaded attachment again because we have it locally. + verifyNoMoreInteractions(remoteStorage); + }); + + test('skip failed download', () async { + Future errorHandler( + Attachment attachment, Object exception, StackTrace trace) async { + return false; + } + + queue = AttachmentQueue( + db: db, + remoteStorage: remoteStorage, + watchAttachments: watchAttachments, + localStorage: localStorage, + errorHandler: AttachmentErrorHandler( + onDeleteError: expectAsync3(errorHandler, count: 0), + onDownloadError: expectAsync3(errorHandler, count: 1), + onUploadError: expectAsync3(errorHandler, count: 0), + ), + ); + + when(remoteStorage.downloadFile(any)).thenAnswer((_) async { + throw 'test error'; + }); + + await queue.startSync(); + await db.execute( + 'INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), ?, ?, uuid())', + ['steven', 'steven@journeyapps.com'], + ); + + expect(await attachments.next, [ + isA() + .having((e) => e.state, 'state', AttachmentState.queuedDownload) + ]); + expect(await attachments.next, [ + isA() + .having((e) => e.state, 'state', AttachmentState.archived) + ]); + }); +} + +extension on PowerSyncDatabase { + Stream> get attachments { + return watch('SELECT * FROM attachments_queue') + .map((rs) => rs.map(Attachment.fromRow).toList()); + } +} + +final class MockRemoteStorage extends Mock implements RemoteStorage { + MockRemoteStorage() { + when(uploadFile(any, any)).thenAnswer((_) async {}); + when(downloadFile(any)).thenAnswer((_) async { + return Stream.empty(); + }); + when(deleteFile(any)).thenAnswer((_) async {}); + } + + @override + Future uploadFile( + Stream? fileData, Attachment? attachment) async { + await noSuchMethod(Invocation.method(#uploadFile, [fileData, attachment])); + } + + @override + Future>> downloadFile(Attachment? attachment) { + return (noSuchMethod(Invocation.method(#downloadFile, [attachment])) ?? + Future.value(const Stream>.empty())) + as Future>>; + } + + @override + Future deleteFile(Attachment? attachment) async { + await noSuchMethod(Invocation.method(#deleteFile, [attachment])); + } +} + +final _schema = Schema([ + Table('users', + [Column.text('name'), Column.text('email'), Column.text('photo_id')]), + AttachmentsQueueTable(), +]); + +TypeMatcher isAttachment(String id) { + return isA().having((e) => e.id, 'id', id); +} diff --git a/packages/powersync_core/test/attachments/local_storage_test.dart b/packages/powersync_core/test/attachments/local_storage_test.dart new file mode 100644 index 00000000..9ceabac1 --- /dev/null +++ b/packages/powersync_core/test/attachments/local_storage_test.dart @@ -0,0 +1,365 @@ +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:path/path.dart' as p; +import 'package:powersync_core/src/attachments/io_local_storage.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('IOLocalStorage', () { + late IOLocalStorage storage; + + setUp(() async { + storage = IOLocalStorage(Directory(d.sandbox)); + }); + + tearDown(() async { + // Clean up is handled automatically by test_descriptor + // No manual cleanup needed + }); + + group('saveFile and readFile', () { + test('saves and reads binary data successfully', () async { + const filePath = 'test_file'; + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + + // Assert filesystem state using test_descriptor + await d.file(filePath, data).validate(); + }); + + test('throws when reading non-existent file', () async { + const filePath = 'non_existent'; + expect( + () => storage.readFile(filePath).toList(), + throwsA(isA()), + ); + + // Assert file does not exist using Dart's File API + expect(await File(p.join(d.sandbox, filePath)).exists(), isFalse); + }); + + test('creates parent directories if they do not exist', () async { + const filePath = 'subdir/nested/test'; + final nonExistentDir = Directory(p.join(d.sandbox, 'subdir', 'nested')); + final data = Uint8List.fromList([1, 2, 3]); + + expect(await nonExistentDir.exists(), isFalse); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + expect(await nonExistentDir.exists(), isTrue); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + + // Assert directory structure + await d.dir('subdir/nested', [d.file('test', data)]).validate(); + }); + + test('creates all parent directories for deeply nested file', () async { + const filePath = 'a/b/c/d/e/f/g/h/i/j/testfile'; + final nestedDir = Directory( + p.join(d.sandbox, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'), + ); + final data = Uint8List.fromList([42, 43, 44]); + + expect(await nestedDir.exists(), isFalse); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + expect(await nestedDir.exists(), isTrue); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + + // Assert deep directory structure + await d.dir('a/b/c/d/e/f/g/h/i/j', [ + d.file('testfile', data), + ]).validate(); + }); + + test('overwrites existing file', () async { + const filePath = 'overwrite_test'; + final originalData = Uint8List.fromList([1, 2, 3]); + final newData = Uint8List.fromList([4, 5, 6, 7]); + + await storage.saveFile(filePath, Stream.value(originalData)); + final size = await storage.saveFile(filePath, Stream.value(newData)); + expect(size, equals(newData.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([newData])); + + // Assert file content + await d.file(filePath, newData).validate(); + }); + }); + + group('edge cases and robustness', () { + test('saveFile with empty data writes empty file and returns 0 size', + () async { + const filePath = 'empty_file'; + + final size = await storage.saveFile(filePath, Stream.empty()); + expect(size, 0); + + final resultStream = storage.readFile(filePath); + final chunks = await resultStream.toList(); + expect(chunks, isEmpty); + + final file = File(p.join(d.sandbox, filePath)); + expect(await file.exists(), isTrue); + expect(await file.length(), 0); + }); + + test('readFile preserves byte order (chunking may differ)', () async { + const filePath = 'ordered_chunks'; + final chunks = [ + Uint8List.fromList([0, 1, 2]), + Uint8List.fromList([3, 4]), + Uint8List.fromList([5, 6, 7, 8]), + ]; + final expectedBytes = + Uint8List.fromList(chunks.expand((c) => c).toList()); + await storage.saveFile(filePath, Stream.value(expectedBytes)); + + final outChunks = await storage.readFile(filePath).toList(); + final outBytes = Uint8List.fromList( + outChunks.expand((c) => c).toList(), + ); + expect(outBytes, equals(expectedBytes)); + }); + + test('fileExists becomes false after deleteFile', () async { + const filePath = 'exists_then_delete'; + await storage.saveFile(filePath, Stream.value(Uint8List.fromList([1]))); + expect(await storage.fileExists(filePath), isTrue); + await storage.deleteFile(filePath); + expect(await storage.fileExists(filePath), isFalse); + }); + + test('initialize is idempotent', () async { + await storage.initialize(); + await storage.initialize(); + + // Create a file, then re-initialize again + const filePath = 'idempotent_test'; + await storage.saveFile(filePath, Stream.value(Uint8List.fromList([9]))); + await storage.initialize(); + + // File should still exist (initialize should not clear data) + expect(await storage.fileExists(filePath), isTrue); + }); + + test('clear works even if base directory was removed externally', + () async { + await storage.initialize(); + + // Remove the base dir manually + final baseDir = Directory(d.sandbox); + if (await baseDir.exists()) { + await baseDir.delete(recursive: true); + } + + // Calling clear should recreate base dir + await storage.clear(); + expect(await baseDir.exists(), isTrue); + }); + + test('supports unicode and emoji filenames', () async { + const filePath = '測試_файл_📷.bin'; + final bytes = Uint8List.fromList([10, 20, 30, 40]); + await storage.saveFile(filePath, Stream.value(bytes)); + + final out = await storage.readFile(filePath).toList(); + expect(out, equals([bytes])); + + await d.file(filePath, bytes).validate(); + }); + + test('readFile accepts mediaType parameter (ignored by IO impl)', + () async { + const filePath = 'with_media_type'; + final data = Uint8List.fromList([1, 2, 3]); + await storage.saveFile(filePath, Stream.value(data)); + + final result = + await storage.readFile(filePath, mediaType: 'image/jpeg').toList(); + expect(result, equals([data])); + }); + }); + + group('deleteFile', () { + test('deletes existing file', () async { + const filePath = 'delete_test'; + final data = Uint8List.fromList([1, 2, 3]); + + await storage.saveFile(filePath, Stream.value(data)); + expect(await storage.fileExists(filePath), isTrue); + + await storage.deleteFile(filePath); + expect(await storage.fileExists(filePath), isFalse); + + // Assert file does not exist + expect(await File(p.join(d.sandbox, filePath)).exists(), isFalse); + }); + + test('does not throw when deleting non-existent file', () async { + const filePath = 'non_existent'; + await storage.deleteFile(filePath); + expect(await File(p.join(d.sandbox, filePath)).exists(), isFalse); + }); + }); + + group('initialize and clear', () { + test('initialize creates the base directory', () async { + final newStorage = + IOLocalStorage(Directory(p.join(d.sandbox, 'new_dir'))); + final baseDir = Directory(p.join(d.sandbox, 'new_dir')); + + expect(await baseDir.exists(), isFalse); + + await newStorage.initialize(); + + expect(await baseDir.exists(), isTrue); + }); + + test('clear removes and recreates the base directory', () async { + await storage.initialize(); + final testFile = p.join(d.sandbox, 'test_file'); + await File(testFile).writeAsString('test'); + + expect(await File(testFile).exists(), isTrue); + + await storage.clear(); + + expect(await Directory(d.sandbox).exists(), isTrue); + expect(await File(testFile).exists(), isFalse); + }); + }); + + group('fileExists', () { + test('returns true for existing file', () async { + const filePath = 'exists_test'; + final data = Uint8List.fromList([1, 2, 3]); + + await storage.saveFile(filePath, Stream.value(data)); + expect(await storage.fileExists(filePath), isTrue); + + await d.file(filePath, data).validate(); + }); + + test('returns false for non-existent file', () async { + const filePath = 'non_existent'; + expect(await storage.fileExists(filePath), isFalse); + expect(await File(p.join(d.sandbox, filePath)).exists(), isFalse); + }); + }); + + group('file system integration', () { + test('handles special characters in file path', () async { + const filePath = 'file with spaces & symbols!@#'; + final data = Uint8List.fromList([1, 2, 3]); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + + await d.file(filePath, data).validate(); + }); + + test('handles large binary data stream', () async { + const filePath = 'large_file'; + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + final chunkSize = 1000; + final chunks = []; + for (var i = 0; i < data.length; i += chunkSize) { + chunks.add( + Uint8List.fromList( + data.sublist( + i, + i + chunkSize < data.length ? i + chunkSize : data.length, + ), + ), + ); + } + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = Uint8List.fromList( + (await resultStream.toList()).expand((chunk) => chunk).toList(), + ); + expect(result, equals(data)); + + await d.file(filePath, data).validate(); + }); + }); + + group('concurrent operations', () { + test('handles concurrent saves to different files', () async { + final futures = >[]; + final fileCount = 10; + + for (int i = 0; i < fileCount; i++) { + final data = Uint8List.fromList([i, i + 1, i + 2]); + futures.add(storage.saveFile('file_$i', Stream.value(data))); + } + + await Future.wait(futures); + + for (int i = 0; i < fileCount; i++) { + final resultStream = storage.readFile('file_$i'); + final result = await resultStream.toList(); + expect( + result, + equals([ + Uint8List.fromList([i, i + 1, i + 2]), + ]), + ); + await d + .file('file_$i', Uint8List.fromList([i, i + 1, i + 2])) + .validate(); + } + }); + + test('handles concurrent saves to the same file', () async { + const filePath = 'concurrent_test'; + final data1 = Uint8List.fromList([1, 2, 3]); + final data2 = Uint8List.fromList([4, 5, 6]); + final futures = [ + storage.saveFile(filePath, Stream.value(data1)), + storage.saveFile(filePath, Stream.value(data2)), + ]; + + await Future.wait(futures); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, anyOf(equals([data1]), equals([data2]))); + + // Assert one of the possible outcomes + final file = File(p.join(d.sandbox, filePath)); + final fileData = await file.readAsBytes(); + expect(fileData, anyOf(equals(data1), equals(data2))); + }); + }); + }); +} diff --git a/packages/powersync_core/test/crud_test.dart b/packages/powersync_core/test/crud_test.dart index 4c18a4b2..0e188075 100644 --- a/packages/powersync_core/test/crud_test.dart +++ b/packages/powersync_core/test/crud_test.dart @@ -271,6 +271,39 @@ void main() { expect(await powersync.getNextCrudTransaction(), equals(null)); }); + test('nextCrudTransactions', () async { + Future createTransaction(int size) { + return powersync.writeTransaction((tx) async { + for (var i = 0; i < size; i++) { + await tx.execute('INSERT INTO assets (id) VALUES (uuid())'); + } + }); + } + + await expectLater(powersync.getCrudTransactions(), emitsDone); + + await createTransaction(5); + await createTransaction(10); + await createTransaction(15); + + CrudTransaction? lastTransaction; + final batch = []; + await for (final transaction in powersync.getCrudTransactions()) { + batch.addAll(transaction.crud); + lastTransaction = transaction; + + if (batch.length > 10) { + break; + } + } + + expect(batch, hasLength(15)); + await lastTransaction!.complete(); + + final remainingTransaction = await powersync.getNextCrudTransaction(); + expect(remainingTransaction?.crud, hasLength(15)); + }); + test('include metadata', () async { await powersync.updateSchema(Schema([ Table( diff --git a/packages/powersync_core/test/exceptions_test.dart b/packages/powersync_core/test/exceptions_test.dart new file mode 100644 index 00000000..d63a152d --- /dev/null +++ b/packages/powersync_core/test/exceptions_test.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:powersync_core/src/exceptions.dart'; +import 'package:test/test.dart'; + +void main() { + group('SyncResponseException', () { + const errorResponse = + '{"error":{"code":"PSYNC_S2106","status":401,"description":"Authentication required","name":"AuthorizationError"}}'; + + test('fromStreamedResponse', () async { + final exc = await SyncResponseException.fromStreamedResponse( + StreamedResponse(Stream.value(utf8.encode(errorResponse)), 401)); + + expect(exc.statusCode, 401); + expect(exc.description, + 'Request failed: PSYNC_S2106(AuthorizationError): Authentication required'); + }); + + test('fromResponse', () { + final exc = + SyncResponseException.fromResponse(Response(errorResponse, 401)); + expect(exc.statusCode, 401); + expect(exc.description, + 'Request failed: PSYNC_S2106(AuthorizationError): Authentication required'); + }); + + test('with description', () { + const errorResponse = + '{"error":{"code":"PSYNC_S2106","status":401,"description":"Authentication required","name":"AuthorizationError", "details": "Missing authorization header"}}'; + + final exc = + SyncResponseException.fromResponse(Response(errorResponse, 401)); + expect(exc.statusCode, 401); + expect(exc.description, + 'Request failed: PSYNC_S2106(AuthorizationError): Authentication required, Missing authorization header'); + }); + + test('malformed', () { + const malformed = + '{"message":"Route GET:/foo/bar not found","error":"Not Found","statusCode":404}'; + + final exc = SyncResponseException.fromResponse(Response(malformed, 401)); + expect(exc.statusCode, 401); + expect(exc.description, + 'Request failed: {"message":"Route GET:/foo/bar not found","error":"Not Found","statusCode":404}'); + + final exc2 = SyncResponseException.fromResponse(Response( + 'not even json', 500, + reasonPhrase: 'Internal server error')); + expect(exc2.statusCode, 500); + expect(exc2.description, 'Internal server error'); + }); + }); +} diff --git a/packages/powersync_core/test/server/sync_server/in_memory_sync_server.dart b/packages/powersync_core/test/server/sync_server/in_memory_sync_server.dart index 6bea9454..278f014e 100644 --- a/packages/powersync_core/test/server/sync_server/in_memory_sync_server.dart +++ b/packages/powersync_core/test/server/sync_server/in_memory_sync_server.dart @@ -1,28 +1,49 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; +import 'package:bson/bson.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; final class MockSyncService { + final bool useBson; + // Use a queued stream to make tests easier. - StreamController controller = StreamController(); + StreamController controller = + StreamController(); Completer _listener = Completer(); - final router = Router(); + var router = Router(); Object? Function() writeCheckpoint = () { return { 'data': {'write_checkpoint': '10'} }; }; - MockSyncService() { + MockSyncService({this.useBson = false}) { router ..post('/sync/stream', (Request request) async { + if (useBson && + !request.headers['Accept']! + .contains('application/vnd.powersync.bson-stream')) { + throw "Want to serve bson, but client doesn't accept it"; + } + _listener.complete(request); // Respond immediately with a stream - return Response.ok(controller.stream.transform(utf8.encoder), headers: { - 'Content-Type': 'application/x-ndjson', + final bytes = controller.stream.map((line) { + return switch (line) { + final String line => utf8.encode(line), + final Uint8List line => line, + _ => throw ArgumentError.value(line, 'line', 'Unexpected type'), + }; + }); + + return Response.ok(bytes, headers: { + 'Content-Type': useBson + ? 'application/vnd.powersync.bson-stream' + : 'application/x-ndjson', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, context: { @@ -39,12 +60,19 @@ final class MockSyncService { Future get waitForListener => _listener.future; // Queue events which will be sent to connected clients. - void addRawEvent(String data) { + void addRawEvent(Object data) { controller.add(data); } void addLine(Object? message) { - addRawEvent('${json.encode(message)}\n'); + if (useBson) { + // Going through a JSON roundtrip ensures that the message can be + // serialized with the BSON package. + final cleanedMessage = json.decode(json.encode(message)); + addRawEvent(BsonCodec.serialize(cleanedMessage).byteList); + } else { + addRawEvent('${json.encode(message)}\n'); + } } void addKeepAlive([int tokenExpiresIn = 3600]) { diff --git a/packages/powersync_core/test/bucket_storage_test.dart b/packages/powersync_core/test/sync/bucket_storage_test.dart similarity index 99% rename from packages/powersync_core/test/bucket_storage_test.dart rename to packages/powersync_core/test/sync/bucket_storage_test.dart index 94338791..496e5a49 100644 --- a/packages/powersync_core/test/bucket_storage_test.dart +++ b/packages/powersync_core/test/sync/bucket_storage_test.dart @@ -4,8 +4,9 @@ import 'package:powersync_core/src/sync/protocol.dart'; import 'package:sqlite_async/sqlite3_common.dart'; import 'package:test/test.dart'; -import 'utils/abstract_test_utils.dart'; -import 'utils/test_utils_impl.dart'; +import '../utils/abstract_test_utils.dart'; +import '../utils/test_utils_impl.dart'; +import 'utils.dart'; final testUtils = TestUtils(); @@ -39,11 +40,6 @@ const removeAsset1_4 = OplogEntry( const removeAsset1_5 = OplogEntry( opId: '5', op: OpType.remove, rowType: 'assets', rowId: 'O1', checksum: 5); -BucketChecksum checksum( - {required String bucket, required int checksum, int priority = 1}) { - return BucketChecksum(bucket: bucket, priority: priority, checksum: checksum); -} - SyncDataBatch syncDataBatch(List data) { return SyncDataBatch(data); } diff --git a/packages/powersync_core/test/in_memory_sync_test.dart b/packages/powersync_core/test/sync/in_memory_sync_test.dart similarity index 87% rename from packages/powersync_core/test/in_memory_sync_test.dart rename to packages/powersync_core/test/sync/in_memory_sync_test.dart index 1819795d..87c18d82 100644 --- a/packages/powersync_core/test/in_memory_sync_test.dart +++ b/packages/powersync_core/test/sync/in_memory_sync_test.dart @@ -5,34 +5,48 @@ import 'package:async/async.dart'; import 'package:logging/logging.dart'; import 'package:powersync_core/powersync_core.dart'; import 'package:powersync_core/sqlite3_common.dart'; -import 'package:powersync_core/src/sync/streaming_sync.dart'; import 'package:powersync_core/src/sync/protocol.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; import 'package:test/test.dart'; -import 'bucket_storage_test.dart'; -import 'server/sync_server/in_memory_sync_server.dart'; -import 'utils/abstract_test_utils.dart'; -import 'utils/in_memory_http.dart'; -import 'utils/test_utils_impl.dart'; +import '../server/sync_server/in_memory_sync_server.dart'; +import '../utils/abstract_test_utils.dart'; +import '../utils/in_memory_http.dart'; +import '../utils/test_utils_impl.dart'; +import 'utils.dart'; void main() { _declareTests( 'dart sync client', SyncOptions( - // ignore: deprecated_member_use_from_same_package - syncImplementation: SyncClientImplementation.dart, - retryDelay: Duration(milliseconds: 200)), + // ignore: deprecated_member_use_from_same_package + syncImplementation: SyncClientImplementation.dart, + retryDelay: Duration(milliseconds: 200), + ), + false, ); - _declareTests( - 'rust sync client', - SyncOptions( - syncImplementation: SyncClientImplementation.rust, - retryDelay: Duration(milliseconds: 200)), - ); + group('rust sync client', () { + _declareTests( + 'json', + SyncOptions( + syncImplementation: SyncClientImplementation.rust, + retryDelay: Duration(milliseconds: 200)), + false, + ); + + _declareTests( + 'bson', + SyncOptions( + syncImplementation: SyncClientImplementation.rust, + retryDelay: Duration(milliseconds: 200)), + true, + ); + }); } -void _declareTests(String name, SyncOptions options) { +void _declareTests(String name, SyncOptions options, bool bson) { final ignoredLogger = Logger.detached('powersync.test')..level = Level.OFF; group(name, () { @@ -40,46 +54,42 @@ void _declareTests(String name, SyncOptions options) { late TestPowerSyncFactory factory; late CommonDatabase raw; - late PowerSyncDatabase database; + late TestDatabase database; late MockSyncService syncService; late Logger logger; - late StreamingSync syncClient; var credentialsCallbackCount = 0; Future Function(PowerSyncDatabase) uploadData = (db) async {}; - void createSyncClient({Schema? schema}) { + Future connect() async { final (client, server) = inMemoryServer(); - server.mount(syncService.router.call); - - final thisSyncClient = syncClient = database.connectWithMockService( - client, - TestConnector(() async { - credentialsCallbackCount++; - return PowerSyncCredentials( - endpoint: server.url.toString(), - token: 'token$credentialsCallbackCount', - expiresAt: DateTime.now(), - ); - }, uploadData: (db) => uploadData(db)), + server.mount((req) => syncService.router(req)); + + database.httpClient = client; + await database.connect( + connector: TestConnector( + () async { + credentialsCallbackCount++; + return PowerSyncCredentials( + endpoint: server.url.toString(), + token: 'token$credentialsCallbackCount', + expiresAt: DateTime.now(), + ); + }, + uploadData: (db) => uploadData(db), + ), options: options, - customSchema: schema, ); - - addTearDown(() async { - await thisSyncClient.abort(); - }); } setUp(() async { logger = Logger.detached('powersync.active')..level = Level.ALL; credentialsCallbackCount = 0; - syncService = MockSyncService(); + syncService = MockSyncService(useBson: bson); factory = await testUtils.testFactory(); (raw, database) = await factory.openInMemoryDatabase(); await database.initialize(); - createSyncClient(); }); tearDown(() async { @@ -96,7 +106,7 @@ void _declareTests(String name, SyncOptions options) { } }); } - syncClient.streamingSync(); + await connect(); await syncService.waitForListener; expect(database.currentStatus.lastSyncedAt, isNull); @@ -131,7 +141,7 @@ void _declareTests(String name, SyncOptions options) { }); await expectLater( status, emits(isSyncStatus(downloading: false, hasSynced: true))); - await syncClient.abort(); + await database.disconnect(); final independentDb = factory.wrapRaw(raw, logger: ignoredLogger); addTearDown(independentDb.close); @@ -142,7 +152,7 @@ void _declareTests(String name, SyncOptions options) { // A complete sync also means that all partial syncs have completed expect( independentDb.currentStatus - .statusForPriority(BucketPriority(3)) + .statusForPriority(StreamPriority(3)) .hasSynced, isTrue); }); @@ -236,7 +246,7 @@ void _declareTests(String name, SyncOptions options) { database.watch('SELECT * FROM lists', throttle: Duration.zero)); await expectLater(query, emits(isEmpty)); - createSyncClient(schema: schema); + await database.updateSchema(schema); await waitForConnection(); syncService @@ -361,13 +371,13 @@ void _declareTests(String name, SyncOptions options) { status, emitsThrough( isSyncStatus(downloading: true, hasSynced: false).having( - (e) => e.statusForPriority(BucketPriority(0)).hasSynced, + (e) => e.statusForPriority(StreamPriority(0)).hasSynced, 'status for $prio', isTrue, )), ); - await database.waitForFirstSync(priority: BucketPriority(prio)); + await database.waitForFirstSync(priority: StreamPriority(prio)); expect(await database.getAll('SELECT * FROM customers'), hasLength(prio + 1)); } @@ -404,9 +414,9 @@ void _declareTests(String name, SyncOptions options) { 'priority': 1, } }); - await database.waitForFirstSync(priority: BucketPriority(1)); + await database.waitForFirstSync(priority: StreamPriority(1)); expect(database.currentStatus.hasSynced, isFalse); - await syncClient.abort(); + await database.disconnect(); final independentDb = factory.wrapRaw(raw, logger: ignoredLogger); addTearDown(independentDb.close); @@ -415,12 +425,12 @@ void _declareTests(String name, SyncOptions options) { // Completing a sync for prio 1 implies a completed sync for prio 0 expect( independentDb.currentStatus - .statusForPriority(BucketPriority(0)) + .statusForPriority(StreamPriority(0)) .hasSynced, isTrue); expect( independentDb.currentStatus - .statusForPriority(BucketPriority(3)) + .statusForPriority(StreamPriority(3)) .hasSynced, isFalse); }); @@ -608,7 +618,7 @@ void _declareTests(String name, SyncOptions options) { Future expectProgress( StreamQueue status, { required Object total, - Map priorities = const {}, + Map priorities = const {}, }) async { await expectLater( status, @@ -668,10 +678,9 @@ void _declareTests(String name, SyncOptions options) { await expectProgress(status, total: progress(5, 10)); // Emulate the app closing - create a new independent sync client. - await syncClient.abort(); + await database.disconnect(); syncService.endCurrentListener(); - createSyncClient(); status = await waitForConnection(); // Send same checkpoint again @@ -702,10 +711,9 @@ void _declareTests(String name, SyncOptions options) { await expectProgress(status, total: progress(5, 10)); // Emulate the app closing - create a new independent sync client. - await syncClient.abort(); + await database.disconnect(); syncService.endCurrentListener(); - createSyncClient(); status = await waitForConnection(); // Send checkpoint with additional data @@ -736,9 +744,9 @@ void _declareTests(String name, SyncOptions options) { // A sync rule deploy could reset buckets, making the new bucket smaller // than the existing one. - await syncClient.abort(); + await database.disconnect(); syncService.endCurrentListener(); - createSyncClient(); + status = await waitForConnection(); syncService.addLine({ 'checkpoint': Checkpoint( @@ -757,8 +765,8 @@ void _declareTests(String name, SyncOptions options) { await expectProgress( status, priorities: { - BucketPriority(0): prio0, - BucketPriority(2): prio2, + StreamPriority(0): prio0, + StreamPriority(2): prio2, }, total: prio2, ); @@ -822,7 +830,7 @@ void _declareTests(String name, SyncOptions options) { }); await expectLater(status, emits(isSyncStatus(downloading: true))); - await syncClient.abort(); + await database.disconnect(); expect(syncService.controller.hasListener, isFalse); }); @@ -841,9 +849,6 @@ void _declareTests(String name, SyncOptions options) { syncService.addLine({ 'checkpoint_complete': {'last_op_id': '10'} }); - - await pumpEventQueue(); - expect(syncService.controller.hasListener, isFalse); syncService.endCurrentListener(); // Should reconnect after delay. @@ -863,9 +868,6 @@ void _declareTests(String name, SyncOptions options) { await expectLater(status, emits(isSyncStatus(downloading: true))); syncService.addKeepAlive(0); - - await pumpEventQueue(); - expect(syncService.controller.hasListener, isFalse); syncService.endCurrentListener(); // Should reconnect after delay. @@ -924,53 +926,45 @@ void _declareTests(String name, SyncOptions options) { expect(await query.next, 'from server'); }); - }); -} -TypeMatcher isSyncStatus({ - Object? downloading, - Object? connected, - Object? connecting, - Object? hasSynced, - Object? downloadProgress, -}) { - var matcher = isA(); - if (downloading != null) { - matcher = matcher.having((e) => e.downloading, 'downloading', downloading); - } - if (connected != null) { - matcher = matcher.having((e) => e.connected, 'connected', connected); - } - if (connecting != null) { - matcher = matcher.having((e) => e.connecting, 'connecting', connecting); - } - if (hasSynced != null) { - matcher = matcher.having((e) => e.hasSynced, 'hasSynced', hasSynced); - } - if (downloadProgress != null) { - matcher = matcher.having( - (e) => e.downloadProgress, 'downloadProgress', downloadProgress); - } - - return matcher; -} + group('abort', () { + test('during connect', () async { + final requestStarted = Completer(); -TypeMatcher isSyncDownloadProgress({ - required Object progress, - Map priorities = const {}, -}) { - var matcher = - isA().having((e) => e, 'untilCompletion', progress); - priorities.forEach((priority, expected) { - matcher = matcher.having( - (e) => e.untilPriority(priority), 'untilPriority($priority)', expected); - }); + syncService.router = Router() + ..post('/sync/stream', expectAsync1((Request request) async { + requestStarted.complete(); - return matcher; -} + // emulate a network that never connects + await Completer().future; + })); -TypeMatcher progress(int completed, int total) { - return isA() - .having((e) => e.downloadedOperations, 'completed', completed) - .having((e) => e.totalOperations, 'total', total); + await connect(); + await requestStarted.future; + expect(database.currentStatus, isSyncStatus(connecting: true)); + + await database.disconnect(); + expect(database.currentStatus.anyError, isNull); + }); + + test('during stream', () async { + final status = await waitForConnection(); + syncService.addLine({ + 'checkpoint': { + 'last_op_id': '0', + 'buckets': [ + { + 'bucket': 'bkt', + 'checksum': 0, + } + ], + }, + }); + await expectLater(status, emits(isSyncStatus(downloading: true))); + + await database.disconnect(); + expect(database.currentStatus.anyError, isNull); + }); + }); + }); } diff --git a/packages/powersync_core/test/sync/stream_test.dart b/packages/powersync_core/test/sync/stream_test.dart new file mode 100644 index 00000000..1625656c --- /dev/null +++ b/packages/powersync_core/test/sync/stream_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync_core/powersync_core.dart'; + +import 'package:test/test.dart'; + +import '../server/sync_server/in_memory_sync_server.dart'; +import '../utils/abstract_test_utils.dart'; +import '../utils/in_memory_http.dart'; +import '../utils/test_utils_impl.dart'; +import 'utils.dart'; + +void main() { + late final testUtils = TestUtils(); + + late TestPowerSyncFactory factory; + + late TestDatabase database; + late MockSyncService syncService; + late Logger logger; + late SyncOptions options; + + var credentialsCallbackCount = 0; + + Future connect() async { + final (client, server) = inMemoryServer(); + server.mount(syncService.router.call); + + database.httpClient = client; + await database.connect( + connector: TestConnector( + () async { + credentialsCallbackCount++; + return PowerSyncCredentials( + endpoint: server.url.toString(), + token: 'token$credentialsCallbackCount', + expiresAt: DateTime.now(), + ); + }, + uploadData: (db) async {}, + ), + options: options, + ); + } + + setUp(() async { + options = SyncOptions(syncImplementation: SyncClientImplementation.rust); + logger = Logger.detached('powersync.active')..level = Level.ALL; + credentialsCallbackCount = 0; + syncService = MockSyncService(); + + factory = await testUtils.testFactory(); + (_, database) = await factory.openInMemoryDatabase(); + await database.initialize(); + }); + + tearDown(() async { + await database.close(); + await syncService.stop(); + }); + + Future> waitForConnection( + {bool expectNoWarnings = true}) async { + if (expectNoWarnings) { + logger.onRecord.listen((e) { + if (e.level >= Level.WARNING) { + fail('Unexpected log: $e, ${e.stackTrace}'); + } + }); + } + await connect(); + await syncService.waitForListener; + + expect(database.currentStatus.lastSyncedAt, isNull); + expect(database.currentStatus.downloading, isFalse); + final status = StreamQueue(database.statusStream); + addTearDown(status.cancel); + + syncService.addKeepAlive(); + await expectLater( + status, emitsThrough(isSyncStatus(connected: true, hasSynced: false))); + return status; + } + + test('can disable default streams', () async { + options = SyncOptions( + syncImplementation: SyncClientImplementation.rust, + includeDefaultStreams: false, + ); + + await waitForConnection(); + final request = await syncService.waitForListener; + expect(json.decode(await request.readAsString()), + containsPair('streams', containsPair('include_defaults', false))); + }); + + test('subscribes with streams', () async { + final a = await database.syncStream('stream', {'foo': 'a'}).subscribe(); + final b = await database.syncStream('stream', {'foo': 'b'}).subscribe( + priority: StreamPriority(1)); + + final statusStream = await waitForConnection(); + final request = await syncService.waitForListener; + expect( + json.decode(await request.readAsString()), + containsPair( + 'streams', + containsPair('subscriptions', [ + { + 'stream': 'stream', + 'parameters': {'foo': 'a'}, + 'override_priority': null, + }, + { + 'stream': 'stream', + 'parameters': {'foo': 'b'}, + 'override_priority': 1, + }, + ]), + ), + ); + + syncService.addLine( + checkpoint( + lastOpId: 0, + buckets: [ + bucketDescription('a', subscriptions: [ + {'sub': 0} + ]), + bucketDescription('b', priority: 1, subscriptions: [ + {'sub': 1} + ]) + ], + streams: [ + stream('stream', false), + ], + ), + ); + + var status = await statusStream.next; + for (final subscription in [a, b]) { + expect(status.forStream(subscription)!.subscription.active, true); + expect(status.forStream(subscription)!.subscription.lastSyncedAt, isNull); + expect( + status.forStream(subscription)!.subscription.hasExplicitSubscription, + true, + ); + } + + syncService.addLine(checkpointComplete(priority: 1)); + status = await statusStream.next; + expect(status.forStream(a)!.subscription.lastSyncedAt, isNull); + expect(status.forStream(b)!.subscription.lastSyncedAt, isNotNull); + await b.waitForFirstSync(); + + syncService.addLine(checkpointComplete()); + await a.waitForFirstSync(); + }); + + test('reports default streams', () async { + final status = await waitForConnection(); + syncService.addLine( + checkpoint(lastOpId: 0, streams: [stream('default_stream', true)]), + ); + + await expectLater( + status, + emits( + isSyncStatus( + syncStreams: [ + isStreamStatus( + subscription: isSyncSubscription( + name: 'default_stream', + parameters: null, + isDefault: true, + ), + ), + ], + ), + ), + ); + }); + + test('changes subscriptions dynamically', () async { + await waitForConnection(); + syncService.addKeepAlive(); + + final subscription = await database.syncStream('a').subscribe(); + syncService.endCurrentListener(); + final request = await syncService.waitForListener; + expect( + json.decode(await request.readAsString()), + containsPair( + 'streams', + containsPair('subscriptions', [ + { + 'stream': 'a', + 'parameters': null, + 'override_priority': null, + }, + ]), + ), + ); + + // Given that the subscription has a TTL, dropping the handle should not + // re-subscribe. + subscription.unsubscribe(); + await pumpEventQueue(); + expect(syncService.controller.hasListener, isTrue); + }); + + test('subscriptions update while offline', () async { + final stream = StreamQueue(database.statusStream); + + final subscription = await database.syncStream('foo').subscribe(); + var status = await stream.next; + expect(status.forStream(subscription), isNotNull); + }); + + test('unsubscribing multiple times has no effect', () async { + final a = await database.syncStream('a').subscribe(); + final aAgain = await database.syncStream('a').subscribe(); + a.unsubscribe(); + a.unsubscribe(); // Should not decrement the refcount again + + // Pretend the streams are expired - they should still be requested because + // the core extension extends the lifetime of streams currently referenced + // before connecting. + await database.execute( + 'UPDATE ps_stream_subscriptions SET expires_at = unixepoch() - 1000'); + + await waitForConnection(); + final request = await syncService.waitForListener; + expect( + json.decode(await request.readAsString()), + containsPair( + 'streams', + containsPair('subscriptions', isNotEmpty), + ), + ); + aAgain.unsubscribe(); + }); + + test('unsubscribeAll', () async { + final a = await database.syncStream('a').subscribe(); + await database.syncStream('a').unsubscribeAll(); + + // Despite a being active, it should not be requested. + await waitForConnection(); + final request = await syncService.waitForListener; + expect( + json.decode(await request.readAsString()), + containsPair( + 'streams', + containsPair('subscriptions', isEmpty), + ), + ); + a.unsubscribe(); + }); +} diff --git a/packages/powersync_core/test/streaming_sync_test.dart b/packages/powersync_core/test/sync/streaming_sync_test.dart similarity index 97% rename from packages/powersync_core/test/streaming_sync_test.dart rename to packages/powersync_core/test/sync/streaming_sync_test.dart index 40becd16..5017993f 100644 --- a/packages/powersync_core/test/streaming_sync_test.dart +++ b/packages/powersync_core/test/sync/streaming_sync_test.dart @@ -9,10 +9,10 @@ import 'package:logging/logging.dart'; import 'package:powersync_core/powersync_core.dart'; import 'package:test/test.dart'; -import 'server/sync_server/in_memory_sync_server.dart'; -import 'test_server.dart'; -import 'utils/abstract_test_utils.dart'; -import 'utils/test_utils_impl.dart'; +import '../server/sync_server/in_memory_sync_server.dart'; +import '../test_server.dart'; +import '../utils/abstract_test_utils.dart'; +import '../utils/test_utils_impl.dart'; final testUtils = TestUtils(); diff --git a/packages/powersync_core/test/sync_types_test.dart b/packages/powersync_core/test/sync/sync_types_test.dart similarity index 95% rename from packages/powersync_core/test/sync_types_test.dart rename to packages/powersync_core/test/sync/sync_types_test.dart index 261152b2..5cd24c9d 100644 --- a/packages/powersync_core/test/sync_types_test.dart +++ b/packages/powersync_core/test/sync/sync_types_test.dart @@ -216,11 +216,11 @@ void main() { } }); - test('bucket priority comparisons', () { - expect(BucketPriority(0) < BucketPriority(3), isFalse); - expect(BucketPriority(0) > BucketPriority(3), isTrue); - expect(BucketPriority(0) >= BucketPriority(3), isTrue); - expect(BucketPriority(0) >= BucketPriority(0), isTrue); + test('stream priority comparisons', () { + expect(StreamPriority(0) < StreamPriority(3), isFalse); + expect(StreamPriority(0) > StreamPriority(3), isTrue); + expect(StreamPriority(0) >= StreamPriority(3), isTrue); + expect(StreamPriority(0) >= StreamPriority(0), isTrue); }); }); } diff --git a/packages/powersync_core/test/sync/utils.dart b/packages/powersync_core/test/sync/utils.dart new file mode 100644 index 00000000..53654f12 --- /dev/null +++ b/packages/powersync_core/test/sync/utils.dart @@ -0,0 +1,136 @@ +import 'package:powersync_core/powersync_core.dart'; +import 'package:powersync_core/src/sync/protocol.dart'; +import 'package:test/test.dart'; + +TypeMatcher isSyncStatus({ + Object? downloading, + Object? connected, + Object? connecting, + Object? hasSynced, + Object? downloadProgress, + Object? syncStreams, +}) { + var matcher = isA(); + if (downloading != null) { + matcher = matcher.having((e) => e.downloading, 'downloading', downloading); + } + if (connected != null) { + matcher = matcher.having((e) => e.connected, 'connected', connected); + } + if (connecting != null) { + matcher = matcher.having((e) => e.connecting, 'connecting', connecting); + } + if (hasSynced != null) { + matcher = matcher.having((e) => e.hasSynced, 'hasSynced', hasSynced); + } + if (downloadProgress != null) { + matcher = matcher.having( + (e) => e.downloadProgress, 'downloadProgress', downloadProgress); + } + if (syncStreams != null) { + matcher = matcher.having((e) => e.syncStreams, 'syncStreams', syncStreams); + } + + return matcher; +} + +TypeMatcher isSyncDownloadProgress({ + required Object progress, + Map priorities = const {}, +}) { + var matcher = + isA().having((e) => e, 'untilCompletion', progress); + priorities.forEach((priority, expected) { + matcher = matcher.having( + (e) => e.untilPriority(priority), 'untilPriority($priority)', expected); + }); + + return matcher; +} + +TypeMatcher progress(int completed, int total) { + return isA() + .having((e) => e.downloadedOperations, 'completed', completed) + .having((e) => e.totalOperations, 'total', total); +} + +TypeMatcher isStreamStatus({ + required Object? subscription, + Object? progress, +}) { + var matcher = isA() + .having((e) => e.subscription, 'subscription', subscription); + if (progress case final progress?) { + matcher = matcher.having((e) => e.progress, 'progress', progress); + } + + return matcher; +} + +TypeMatcher isSyncSubscription({ + required Object name, + required Object? parameters, + bool? isDefault, +}) { + var matcher = isA() + .having((e) => e.name, 'name', name) + .having((e) => e.parameters, 'parameters', parameters); + + if (isDefault != null) { + matcher = matcher.having((e) => e.isDefault, 'isDefault', isDefault); + } + + return matcher; +} + +BucketChecksum checksum( + {required String bucket, required int checksum, int priority = 1}) { + return BucketChecksum(bucket: bucket, priority: priority, checksum: checksum); +} + +/// Creates a `checkpoint` line. +Object checkpoint({ + required int lastOpId, + List buckets = const [], + String? writeCheckpoint, + List streams = const [], +}) { + return { + 'checkpoint': { + 'last_op_id': '$lastOpId', + 'write_checkpoint': null, + 'buckets': buckets, + 'streams': streams, + } + }; +} + +Object stream(String name, bool isDefault, {List errors = const []}) { + return {'name': name, 'is_default': isDefault, 'errors': errors}; +} + +/// Creates a `checkpoint_complete` or `partial_checkpoint_complete` line. +Object checkpointComplete({int? priority, String lastOpId = '1'}) { + return { + priority == null ? 'checkpoint_complete' : 'partial_checkpoint_complete': { + 'last_op_id': lastOpId, + if (priority != null) 'priority': priority, + }, + }; +} + +Object bucketDescription( + String name, { + int checksum = 0, + int priority = 3, + int count = 1, + Object? subscriptions, +}) { + return { + 'bucket': name, + 'checksum': checksum, + 'priority': priority, + 'count': count, + if (subscriptions != null) 'subscriptions': subscriptions, + }; +} diff --git a/packages/powersync_core/test/utils/abstract_test_utils.dart b/packages/powersync_core/test/utils/abstract_test_utils.dart index 6a1a90aa..96469c5a 100644 --- a/packages/powersync_core/test/utils/abstract_test_utils.dart +++ b/packages/powersync_core/test/utils/abstract_test_utils.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:powersync_core/powersync_core.dart'; +import 'package:powersync_core/src/abort_controller.dart'; +import 'package:powersync_core/src/database/powersync_db_mixin.dart'; import 'package:powersync_core/src/sync/bucket_storage.dart'; import 'package:powersync_core/src/sync/internal_connector.dart'; import 'package:powersync_core/src/sync/options.dart'; @@ -51,7 +54,7 @@ Logger _makeTestLogger({Level level = Level.ALL, String? name}) { // Hack to fail the test if a SEVERE error is logged. // Not ideal, but works to catch "Sync Isolate error". uncaughtError() async { - throw record.error!; + throw 'Unexpected severe error on logger: ${record.error!}'; } uncaughtError(); @@ -63,20 +66,24 @@ Logger _makeTestLogger({Level level = Level.ALL, String? name}) { abstract mixin class TestPowerSyncFactory implements PowerSyncOpenFactory { Future openRawInMemoryDatabase(); - Future<(CommonDatabase, PowerSyncDatabase)> openInMemoryDatabase() async { + Future<(CommonDatabase, TestDatabase)> openInMemoryDatabase({ + Schema? schema, + Logger? logger, + }) async { final raw = await openRawInMemoryDatabase(); - return (raw, wrapRaw(raw)); + return (raw, wrapRaw(raw, customSchema: schema, logger: logger)); } - PowerSyncDatabase wrapRaw( + TestDatabase wrapRaw( CommonDatabase raw, { Logger? logger, + Schema? customSchema, }) { - return PowerSyncDatabase.withDatabase( - schema: schema, + return TestDatabase( database: SqliteDatabase.singleConnection( SqliteConnection.synchronousWrapper(raw)), - logger: logger, + logger: logger ?? Logger.detached('PowerSync.test'), + schema: customSchema ?? schema, ); } } @@ -147,6 +154,83 @@ class TestConnector extends PowerSyncBackendConnector { } } +/// A [PowerSyncDatabase] implemented by a single in-memory database connection +/// and a mock-HTTP sync client. +/// +/// This ensures tests for sync cover the `ConnectionManager` and other methods +/// exposed by the mixin. +final class TestDatabase + with SqliteQueries, PowerSyncDatabaseMixin + implements PowerSyncDatabase { + @override + final SqliteDatabase database; + @override + final Logger logger; + @override + Schema schema; + + @override + late final Future isInitialized; + + Client? httpClient; + + TestDatabase({ + required this.database, + required this.logger, + required this.schema, + }) { + isInitialized = baseInit(); + } + + @override + Future connectInternal({ + required PowerSyncBackendConnector connector, + required ResolvedSyncOptions options, + required List initiallyActiveStreams, + required Stream> activeStreams, + required AbortController abort, + required Zone asyncWorkZone, + }) async { + final impl = StreamingSyncImplementation( + adapter: BucketStorage(this), + schemaJson: jsonEncode(schema), + client: httpClient!, + options: options, + connector: InternalConnector.wrap(connector, this), + logger: logger, + crudUpdateTriggerStream: database + .onChange(['ps_crud'], throttle: const Duration(milliseconds: 10)), + activeSubscriptions: initiallyActiveStreams, + ); + impl.statusStream.listen(setStatus); + + asyncWorkZone.run(impl.streamingSync); + final subscriptions = activeStreams.listen(impl.updateSubscriptions); + + abort.onAbort.then((_) async { + subscriptions.cancel(); + await impl.abort(); + abort.completeAbort(); + }).ignore(); + } + + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {String? debugContext, Duration? lockTimeout}) async { + await isInitialized; + return database.readLock(callback, + debugContext: debugContext, lockTimeout: lockTimeout); + } + + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {String? debugContext, Duration? lockTimeout}) async { + await isInitialized; + return database.writeLock(callback, + debugContext: debugContext, lockTimeout: lockTimeout); + } +} + extension MockSync on PowerSyncDatabase { StreamingSyncImplementation connectWithMockService( Client client, diff --git a/packages/powersync_core/test/utils/in_memory_http.dart b/packages/powersync_core/test/utils/in_memory_http.dart index 61550e3c..a35d6a09 100644 --- a/packages/powersync_core/test/utils/in_memory_http.dart +++ b/packages/powersync_core/test/utils/in_memory_http.dart @@ -35,6 +35,11 @@ final class _MockServer implements shelf.Server { Future handleRequest( BaseRequest request, ByteStream body) async { + final cancellationFuture = switch (request) { + Abortable(:final abortTrigger) => abortTrigger, + _ => null, + }; + if (_handler case final endpoint?) { final shelfRequest = shelf.Request( request.method, @@ -42,10 +47,17 @@ final class _MockServer implements shelf.Server { headers: request.headers, body: body, ); - final shelfResponse = await endpoint(shelfRequest); + + final shelfResponse = await Future.any([ + Future.sync(() => endpoint(shelfRequest)), + if (cancellationFuture != null) + cancellationFuture.then((_) { + throw RequestAbortedException(); + }), + ]); return StreamedResponse( - shelfResponse.read(), + shelfResponse.read().injectCancellation(cancellationFuture), shelfResponse.statusCode, headers: shelfResponse.headers, ); @@ -54,3 +66,36 @@ final class _MockServer implements shelf.Server { } } } + +extension on Stream { + Stream injectCancellation(Future? token) { + if (token == null) { + return this; + } + + return Stream.multi( + (listener) { + final subscription = listen( + listener.addSync, + onError: listener.addErrorSync, + onDone: listener.closeSync, + ); + + listener + ..onPause = subscription.pause + ..onResume = subscription.resume + ..onCancel = subscription.cancel; + + token.whenComplete(() { + if (!listener.isClosed) { + listener + ..addErrorSync(RequestAbortedException()) + ..closeSync(); + subscription.cancel(); + } + }); + }, + isBroadcast: isBroadcast, + ); + } +} diff --git a/packages/powersync_flutter_libs/CHANGELOG.md b/packages/powersync_flutter_libs/CHANGELOG.md index 0dc93482..79e23a87 100644 --- a/packages/powersync_flutter_libs/CHANGELOG.md +++ b/packages/powersync_flutter_libs/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.4.12 + + - Update core extension, add support for SwiftPM. + +## 0.4.11 + + - Update PowerSync core extension to version 0.4.4. + ## 0.4.10 - Update PowerSync core extension to version 0.4.2. diff --git a/packages/powersync_flutter_libs/android/build.gradle b/packages/powersync_flutter_libs/android/build.gradle index 91f9cb27..d6c0e3c1 100644 --- a/packages/powersync_flutter_libs/android/build.gradle +++ b/packages/powersync_flutter_libs/android/build.gradle @@ -50,5 +50,5 @@ android { } dependencies { - implementation 'com.powersync:powersync-sqlite-core:0.4.2' + implementation 'com.powersync:powersync-sqlite-core:0.4.6' } diff --git a/packages/powersync_flutter_libs/ios/powersync_flutter_libs.podspec b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs.podspec similarity index 68% rename from packages/powersync_flutter_libs/ios/powersync_flutter_libs.podspec rename to packages/powersync_flutter_libs/darwin/powersync_flutter_libs.podspec index d83fc2c8..b93848a8 100644 --- a/packages/powersync_flutter_libs/ios/powersync_flutter_libs.podspec +++ b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs.podspec @@ -9,20 +9,23 @@ Pod::Spec.new do |s| s.description = <<-DESC A new Flutter FFI plugin project. DESC - s.homepage = 'http://example.com' + s.homepage = 'https://powersync.com' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'Journey Mobile, Inc' => 'hello@powersync.com' } # This will ensure the source files in Classes/ are included in the native # builds of apps using this FFI plugin. Podspec does not support relative # paths, so Classes contains a forwarder C file that relatively imports # `../src/*` so that the C sources can be shared among all target platforms. s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '11.0' + s.source_files = 'powersync_flutter_libs/Sources/powersync_flutter_libs/**/*.swift' + s.ios.dependency 'Flutter' + s.osx.dependency 'FlutterMacOS' + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' - s.dependency "powersync-sqlite-core", "~> 0.4.2" + # NOTE: Always update Package.swift as well when updating this! + s.dependency "powersync-sqlite-core", "~> 0.4.6" # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.resolved b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.resolved new file mode 100644 index 00000000..a62a2d5c --- /dev/null +++ b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "powersync-sqlite-core-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + "state" : { + "revision" : "b2a81af14e9ad83393eb187bb02e62e6db8b5ad6", + "version" : "0.4.6" + } + } + ], + "version" : 2 +} diff --git a/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.swift b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.swift new file mode 100644 index 00000000..cfa880df --- /dev/null +++ b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "powersync_flutter_libs", + platforms: [ + .iOS("13.0"), + .macOS("10.15") + ], + products: [ + .library(name: "powersync-flutter-libs", type: .static, targets: ["powersync_flutter_libs"]) + ], + dependencies: [ + .package( + url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", + // Note: Always update podspec as well when updating this. + exact: "0.4.6" + ) + ], + targets: [ + .target( + name: "powersync_flutter_libs", + dependencies: [ + .product(name: "PowerSyncSQLiteCore", package: "powersync-sqlite-core-swift") + ], + resources: [] + ) + ] +) diff --git a/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Sources/powersync_flutter_libs/PowerSyncFlutterLibsPlugin.swift b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Sources/powersync_flutter_libs/PowerSyncFlutterLibsPlugin.swift new file mode 100644 index 00000000..f3a14c12 --- /dev/null +++ b/packages/powersync_flutter_libs/darwin/powersync_flutter_libs/Sources/powersync_flutter_libs/PowerSyncFlutterLibsPlugin.swift @@ -0,0 +1,13 @@ +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#endif + +public class PowersyncFlutterLibsPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // There's no native code, we just want to link the core extension + } +} diff --git a/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.h b/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.h deleted file mode 100644 index 0ddfb7f7..00000000 --- a/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface PowersyncFlutterLibsPlugin : NSObject -@end diff --git a/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.m b/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.m deleted file mode 100644 index 74d2e35c..00000000 --- a/packages/powersync_flutter_libs/ios/Classes/PowersyncFlutterLibsPlugin.m +++ /dev/null @@ -1,7 +0,0 @@ -#import "PowersyncFlutterLibsPlugin.h" - -@implementation PowersyncFlutterLibsPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - -} -@end diff --git a/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.h b/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.h deleted file mode 100644 index 9725b1d6..00000000 --- a/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface PowersyncFlutterLibsPlugin : NSObject -@end diff --git a/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.m b/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.m deleted file mode 100644 index 74d2e35c..00000000 --- a/packages/powersync_flutter_libs/macos/Classes/PowersyncFlutterLibsPlugin.m +++ /dev/null @@ -1,7 +0,0 @@ -#import "PowersyncFlutterLibsPlugin.h" - -@implementation PowersyncFlutterLibsPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - -} -@end diff --git a/packages/powersync_flutter_libs/macos/powersync_flutter_libs.podspec b/packages/powersync_flutter_libs/macos/powersync_flutter_libs.podspec deleted file mode 100644 index 4519dcb7..00000000 --- a/packages/powersync_flutter_libs/macos/powersync_flutter_libs.podspec +++ /dev/null @@ -1,29 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint powersync_flutter_libs.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'powersync_flutter_libs' - s.version = '0.0.1' - s.summary = 'A new Flutter FFI plugin project.' - s.description = <<-DESC -A new Flutter FFI plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - - # This will ensure the source files in Classes/ are included in the native - # builds of apps using this FFI plugin. Podspec does not support relative - # paths, so Classes contains a forwarder C file that relatively imports - # `../src/*` so that the C sources can be shared among all target platforms. - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'FlutterMacOS' - - s.dependency "powersync-sqlite-core", "~> 0.4.2" - - s.platform = :osx, '10.11' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } - s.swift_version = '5.0' -end diff --git a/packages/powersync_flutter_libs/pubspec.yaml b/packages/powersync_flutter_libs/pubspec.yaml index 24fa7e01..a6afea23 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.10 +version: 0.4.12 repository: https://github.com/powersync-ja/powersync.dart homepage: https://www.powersync.com/ @@ -25,9 +25,11 @@ flutter: pluginClass: PowersyncFlutterLibsPlugin ios: pluginClass: PowersyncFlutterLibsPlugin - linux: - pluginClass: PowersyncFlutterLibsPlugin + sharedDarwinSource: true macos: pluginClass: PowersyncFlutterLibsPlugin + sharedDarwinSource: true + linux: + pluginClass: PowersyncFlutterLibsPlugin windows: pluginClass: PowersyncFlutterLibsPlugin diff --git a/packages/powersync_sqlcipher/CHANGELOG.md b/packages/powersync_sqlcipher/CHANGELOG.md index 08d525b8..33b9cb2f 100644 --- a/packages/powersync_sqlcipher/CHANGELOG.md +++ b/packages/powersync_sqlcipher/CHANGELOG.md @@ -1,3 +1,25 @@ +## 0.1.13 + + - Web: Fix decoding sync streams on status. + +## 0.1.12 + +- Add `getCrudTransactions()` returning a stream of completed transactions for uploads. +- Add experimental support for [sync streams](https://docs.powersync.com/usage/sync-streams). +- Add new attachments helper implementation in `package:powersync_core/attachments/attachments.dart`. +- Add SwiftPM support. + +## 0.1.11+1 + + - Fix excessive memory consumption during large sync. + +## 0.1.11 + + - Support latest versions of `package:sqlite3` and `package:sqlite_async`. + - Stream client: Improve `disconnect()` while a connection is being opened. + - Stream client: Support binary sync lines with Rust client and compatible PowerSync service versions. + - Sync client: Improve parsing error responses. + ## 0.1.10 - raw tables diff --git a/packages/powersync_sqlcipher/example/ios/Podfile b/packages/powersync_sqlcipher/example/ios/Podfile index e549ee22..620e46eb 100644 --- a/packages/powersync_sqlcipher/example/ios/Podfile +++ b/packages/powersync_sqlcipher/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/powersync_sqlcipher/example/macos/Podfile b/packages/powersync_sqlcipher/example/macos/Podfile index 29c8eb32..ff5ddb3b 100644 --- a/packages/powersync_sqlcipher/example/macos/Podfile +++ b/packages/powersync_sqlcipher/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/packages/powersync_sqlcipher/example/pubspec.lock b/packages/powersync_sqlcipher/example/pubspec.lock index 068a9f39..e43cc1be 100644 --- a/packages/powersync_sqlcipher/example/pubspec.lock +++ b/packages/powersync_sqlcipher/example/pubspec.lock @@ -315,29 +315,26 @@ packages: source: hosted version: "2.1.8" powersync_core: - dependency: transitive + dependency: "direct overridden" description: - name: powersync_core - sha256: ad6ffccb5c1e89a9391124a63cd94698946c9bb440a1205725b574c5766b102d - url: "https://pub.dev" - source: hosted - version: "1.3.1" + path: "../../powersync_core" + relative: true + source: path + version: "1.5.0" powersync_flutter_libs: - dependency: transitive + dependency: "direct overridden" description: - name: powersync_flutter_libs - sha256: f1cd9f3a084a39007dd2fb414b03fcc29ab61f0af5d117263fe5f54e407d97d7 - url: "https://pub.dev" - source: hosted - version: "0.4.8" + path: "../../powersync_flutter_libs" + relative: true + source: path + version: "0.4.10" powersync_sqlcipher: dependency: "direct main" description: - name: powersync_sqlcipher - sha256: "33b3d67feab5d4dbf484e2c49e11d7b16f7bd406e70e974a2bbbe439ebc382c4" - url: "https://pub.dev" - source: hosted - version: "0.1.7" + path: ".." + relative: true + source: path + version: "0.1.10" process: dependency: transitive description: diff --git a/packages/powersync_sqlcipher/example/pubspec.yaml b/packages/powersync_sqlcipher/example/pubspec.yaml index a86b0215..753aa73a 100644 --- a/packages/powersync_sqlcipher/example/pubspec.yaml +++ b/packages/powersync_sqlcipher/example/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: path: ^1.9.1 path_provider: ^2.1.5 - powersync_sqlcipher: ^0.1.10 + powersync_sqlcipher: ^0.1.13 dev_dependencies: flutter_test: diff --git a/packages/powersync_sqlcipher/pubspec.yaml b/packages/powersync_sqlcipher/pubspec.yaml index 0cee52bf..b73913c2 100644 --- a/packages/powersync_sqlcipher/pubspec.yaml +++ b/packages/powersync_sqlcipher/pubspec.yaml @@ -1,5 +1,5 @@ name: powersync_sqlcipher -version: 0.1.10 +version: 0.1.13 homepage: https://powersync.com repository: https://github.com/powersync-ja/powersync.dart description: PowerSync Flutter SDK - sync engine for building local-first apps. @@ -12,8 +12,8 @@ dependencies: flutter: sdk: flutter - powersync_core: ^1.5.0 - powersync_flutter_libs: ^0.4.10 + powersync_core: ^1.6.1 + powersync_flutter_libs: ^0.4.12 sqlcipher_flutter_libs: ^0.6.4 sqlite3_web: ^0.3.0 diff --git a/packages/sqlite3_wasm_build/build.sh b/packages/sqlite3_wasm_build/build.sh index fc414ec7..eefbb4ae 100755 --- a/packages/sqlite3_wasm_build/build.sh +++ b/packages/sqlite3_wasm_build/build.sh @@ -1,8 +1,8 @@ #!/bin/sh set -e -SQLITE_VERSION="2.7.6" -POWERSYNC_CORE_VERSION="0.4.2" +SQLITE_VERSION="2.9.0" +POWERSYNC_CORE_VERSION="0.4.6" SQLITE_PATH="sqlite3.dart" if [ -d "$SQLITE_PATH" ]; then @@ -16,7 +16,6 @@ cd $SQLITE_PATH git apply ../patches/* cd "sqlite3/" -dart pub get # We need the analyzer dependency resolved to extract required symbols cmake -Dwasi_sysroot=/opt/homebrew/share/wasi-sysroot \ -Dclang=/opt/homebrew/opt/llvm/bin/clang\ diff --git a/scripts/download_core_binary_demos.dart b/scripts/download_core_binary_demos.dart index e5cb52de..f5453ce1 100644 --- a/scripts/download_core_binary_demos.dart +++ b/scripts/download_core_binary_demos.dart @@ -3,7 +3,7 @@ import 'dart:io'; final coreUrl = - 'https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.4.2'; + 'https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.4.6'; void main() async { final powersyncLibsLinuxPath = "packages/powersync_flutter_libs/linux"; diff --git a/scripts/init_powersync_core_binary.dart b/scripts/init_powersync_core_binary.dart index 1374f441..0c985895 100644 --- a/scripts/init_powersync_core_binary.dart +++ b/scripts/init_powersync_core_binary.dart @@ -6,7 +6,7 @@ import 'dart:io'; import 'package:melos/melos.dart'; final sqliteUrl = - 'https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.4.2'; + 'https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.4.6'; void main() async { final sqliteCoreFilename = getLibraryForPlatform();