diff --git a/.github/workflows/demos.yml b/.github/workflows/demos.yml index f5c41cf0..0ae30733 100644 --- a/.github/workflows/demos.yml +++ b/.github/workflows/demos.yml @@ -23,7 +23,7 @@ jobs: - name: Install Melos run: flutter pub global activate melos - name: Install dependencies - run: melos bootstrap + run: melos prepare - name: Check formatting run: melos format:check:demos - name: Lint @@ -43,11 +43,8 @@ jobs: - name: Install melos run: flutter pub global activate melos - name: Install dependencies - run: melos bootstrap - - name: Download powersync binary - run: | - github="https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.1.6" - - curl "${github}/libpowersync_x64.so" -o packages/powersync/libpowersync.so --create-dirs -L -f - - name: Run tests + run: melos prepare + - name: Run flutter tests run: melos test + - name: Run dart tests + run: melos test:web diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 6526b23f..88acf4bd 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -23,7 +23,7 @@ jobs: - name: Install Melos run: flutter pub global activate melos - name: Install dependencies - run: melos bootstrap + run: melos prepare - name: Check formatting run: melos format:check:packages - name: Lint @@ -33,7 +33,7 @@ jobs: - name: Check publish score run: | flutter pub global activate pana - ./.github/workflows/scripts/run-pana.sh + melos analyze:packages:pana --no-select test: runs-on: ubuntu-latest @@ -46,12 +46,9 @@ jobs: channel: "stable" - name: Install melos run: flutter pub global activate melos - - name: Install dependencies - run: melos bootstrap - - name: Download powersync binary - run: | - github="https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.1.6" - - curl "${github}/libpowersync_x64.so" -o packages/powersync/libpowersync.so --create-dirs -L -f - - name: Run tests + - name: Install dependencies and prepare project + run: melos prepare + - name: Run flutter tests run: melos test + - name: Run dart tests + run: melos test:web diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bfc8fa67..70d6cead 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,7 @@ jobs: permissions: contents: write id-token: write # Required for authentication using OIDC - runs-on: [ ubuntu-latest ] + runs-on: [ubuntu-latest] steps: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5b61047d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +# This triggers whenever a tagged release is pushed +name: Compile Assets and Create Draft Release + +on: + push: + tags: + # Trigger on tags beginning with 'v' + # Note that `melos version ...` adds the package name as a suffix + # This action is not compatible with tags such as `powersync-v1.1.1` + # marvinpinto/action-automatic-releases struggles to generate changelogs + # Be sure to manually tag the commit to trigger this action + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.x' + channel: 'stable' + + - name: Install Melos + run: flutter pub global activate melos + + - name: Install Dependencies and Compile Assets + run: melos prepare + + - name: Create Draft Release + uses: 'marvinpinto/action-automatic-releases@latest' + with: + repo_token: '${{ secrets.GITHUB_TOKEN }}' + prerelease: true # TODO update when out of alpha + draft: true + files: | + assets/powersync_db.worker.js diff --git a/.github/workflows/scripts/run-pana.sh b/.github/workflows/scripts/run-pana.sh deleted file mode 100755 index c117cc98..00000000 --- a/.github/workflows/scripts/run-pana.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -set -e - -# Get the root directory of your project -ROOT_DIR=$(pwd) - -# Specify the path to the packages folder -PACKAGES_DIR="$ROOT_DIR/packages" - -# Iterate over each package folder -for PACKAGE in "$PACKAGES_DIR"/*; do - # Check if it's a directory - if [ -d "$PACKAGE" ]; then - echo "Analyzing package in: $PACKAGE" - - # Change into the package directory - cd "$PACKAGE" || exit - - # Run the pana command - flutter pub global run pana --no-warning --exit-code-threshold 0 - - # Return to the root directory - cd "$ROOT_DIR" || exit - fi -done diff --git a/.gitignore b/.gitignore index f3d06acc..7a899ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,20 @@ .DS_Store pubspec_overrides.yaml .idea +.vscode *.iml .flutter-plugins-dependencies .flutter-plugins +build + +# Shared assets +assets + +# Web assets +powersync_db.worker.js +sqlite3.wasm + +#Core binaries +*.dylib +*.dll +*.so diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a6b650..004dac34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## 2024-07-16 +## 2024-07-18 ### Changes @@ -11,22 +11,178 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline Packages with breaking changes: - - There are no breaking changes in this release. +- There are no breaking changes in this release. Packages with other changes: - - [`powersync` - `v1.5.5`](#powersync---v155) - - [`powersync_attachments_helper` - `v0.5.1+1`](#powersync_attachments_helper---v0511) +- [`powersync` - `v1.6.0-alpha.1`](#powersync---v160-alpha1) +- [`powersync_attachments_helper` - `v0.6.0-alpha.1`](#powersync_attachments_helper---v060-alpha1) Packages with dependency updates only: > Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - `powersync_attachments_helper` - `v0.5.1+1` +- `powersync_attachments_helper` - `v0.6.0-alpha.1` + +--- + +#### `powersync` - `v1.6.0-alpha.1` + +- Added support for client parameters when connecting. +- Fix watch query parameter `triggerOnTables` to prepend powersync view names. +- Upgrade dependency `sqlite_async` to version 0.8.1. +- Fix issue where `hasSynced` is cleared when offline. + +## 2024-07-16 + +### Changes --- +- [`powersync` - `v1.5.5`](#powersync---v155) +- [`powersync_attachments_helper` - `v0.5.1+1`](#powersync_attachments_helper---v0511) + #### `powersync` - `v1.5.5` - - Fix issue where `hasSynced` is cleared when offline. +- Fix issue where `hasSynced` is cleared when offline. + +## 2024-07-10 + +### Changes + +--- + +Packages with breaking changes: + +- There are no breaking changes in this release. + +Packages with other changes: + +- [`powersync` - `v1.3.0-alpha.9`](#powersync---v130-alpha9) +- [`powersync_attachments_helper` - `v0.3.0-alpha.4`](#powersync_attachments_helper---v030-alpha4) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + +- `powersync_attachments_helper` - `v0.3.0-alpha.4` + +--- + +#### `powersync` - `v1.3.0-alpha.9` + +- Updated sqlite_async to use Navigator locks for limiting sync stream implementions in multiple tabs + +## 2024-07-04 + +### Changes + +--- + +Packages with breaking changes: + +- There are no breaking changes in this release. + +Packages with other changes: + +- [`powersync` - `v1.3.0-alpha.8`](#powersync---v130-alpha8) +- [`powersync_attachments_helper` - `v0.3.0-alpha.3`](#powersync_attachments_helper---v030-alpha3) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + +- `powersync_attachments_helper` - `v0.3.0-alpha.3` + +--- + +#### `powersync` - `v1.3.0-alpha.8` + +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **DOCS**: update readme and getting started (#51). + +## 2024-05-30 + +### Changes + +--- + +Packages with breaking changes: + +- [`powersync_attachments_helper` - `v0.3.0-alpha.2`](#powersync_attachments_helper---v030-alpha2) + +Packages with other changes: + +- [`powersync` - `v1.3.0-alpha.5`](#powersync---v130-alpha5) + +--- + +#### `powersync_attachments_helper` - `v0.3.0-alpha.2` + +- **FIX**: reset isProcessing when exception is thrown during sync process. (#81). +- **FIX**: attachment queue duplicating requests (#68). +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **FEAT**(attachments): add error handlers (#65). +- **DOCS**: update readmes (#38). +- **BREAKING** **FEAT**(attachments): cater for subdirectories in storage (#78). + +#### `powersync` - `v1.3.0-alpha.5` + +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **DOCS**: update readme and getting started (#51). + +## 2024-03-05 + +### Changes + +--- + +Packages with breaking changes: + +- There are no breaking changes in this release. + +Packages with other changes: + +- [`powersync` - `v1.3.0-alpha.3`](#powersync---v130-alpha3) +- [`powersync_attachments_helper` - `v0.3.0-alpha.2`](#powersync_attachments_helper---v030-alpha2) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + +- `powersync_attachments_helper` - `v0.3.0-alpha.2` + +--- + +#### `powersync` - `v1.3.0-alpha.3` + +- Fixed issue where disconnectAndClear would prevent subsequent sync connection on native platforms and would fail to clear the database on web. + +## 2024-02-15 + +### Changes + +--- + +Packages with breaking changes: + +- There are no breaking changes in this release. + +Packages with other changes: + +- [`powersync` - `v1.3.0-alpha.2`](#powersync---v130-alpha2) +- [`powersync_attachments_helper` - `v0.3.0-alpha.2`](#powersync_attachments_helper---v030-alpha2) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + +- `powersync_attachments_helper` - `v0.3.0-alpha.2` + +--- + +#### `powersync` - `v1.3.0-alpha.2` +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **DOCS**: update readme and getting started (#51). +- `powersync_attachments_helper` - `v0.5.1+1` diff --git a/README.md b/README.md index 88c1df6a..6577d87f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ This monorepo uses [melos](https://melos.invertase.dev/) to handle command and p For detailed usage, check out the inner [powersync](https://github.com/powersync-ja/powersync.dart/tree/master/packages/powersync) and [attachments helper](https://github.com/powersync-ja/powersync.dart/tree/master/packages/powersync_attachments_helper) packages. +To configure the monorepo for development run `melos prepare` after cloning + #### Blog posts - [Flutter Tutorial: building an offline-first chat app with Supabase and PowerSync](https://www.powersync.com/blog/flutter-tutorial-building-an-offline-first-chat-app-with-supabase-and-powersync) diff --git a/demos/django-todolist/lib/models/todo_item.dart b/demos/django-todolist/lib/models/todo_item.dart index 8c9034dc..e31efea4 100644 --- a/demos/django-todolist/lib/models/todo_item.dart +++ b/demos/django-todolist/lib/models/todo_item.dart @@ -1,7 +1,7 @@ import 'package:powersync_django_todolist_demo/models/schema.dart'; import '../powersync.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; /// TodoItem represents a result row of a query on "todos". /// diff --git a/demos/django-todolist/lib/models/todo_list.dart b/demos/django-todolist/lib/models/todo_list.dart index cc8021e7..643db070 100644 --- a/demos/django-todolist/lib/models/todo_list.dart +++ b/demos/django-todolist/lib/models/todo_list.dart @@ -1,4 +1,4 @@ -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; import './todo_item.dart'; import '../powersync.dart'; diff --git a/demos/django-todolist/lib/widgets/query_widget.dart b/demos/django-todolist/lib/widgets/query_widget.dart index fed1bd78..426c7060 100644 --- a/demos/django-todolist/lib/widgets/query_widget.dart +++ b/demos/django-todolist/lib/widgets/query_widget.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; import './resultset_table.dart'; import '../powersync.dart'; diff --git a/demos/django-todolist/lib/widgets/resultset_table.dart b/demos/django-todolist/lib/widgets/resultset_table.dart index b1606adf..f348e4ff 100644 --- a/demos/django-todolist/lib/widgets/resultset_table.dart +++ b/demos/django-todolist/lib/widgets/resultset_table.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; /// Stateless DataTable rendering results from a SQLite query class ResultSetTable extends StatelessWidget { diff --git a/demos/django-todolist/pubspec.lock b/demos/django-todolist/pubspec.lock index ead7f88f..7c174a21 100644 --- a/demos/django-todolist/pubspec.lock +++ b/demos/django-todolist/pubspec.lock @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -120,6 +136,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" leak_tracker: dependency: transitive description: @@ -184,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + mutex: + dependency: transitive + description: + name: mutex + sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" + url: "https://pub.dev" + source: hosted + version: "3.1.0" path: dependency: "direct main" description: @@ -262,7 +294,7 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.5.3" + version: "1.6.0-alpha.1" powersync_flutter_libs: dependency: "direct overridden" description: @@ -363,14 +395,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.24" + sqlite3_web: + dependency: transitive + description: + name: sqlite3_web + sha256: "51fec34757577841cc72d79086067e3651c434669d5af557a5c106787198a76f" + url: "https://pub.dev" + source: hosted + version: "0.1.2-wip" sqlite_async: dependency: "direct main" description: name: sqlite_async - sha256: "7c121bd76b9063cd8189ce54512f243709c88addeced0f3d027eea5db64d3220" + sha256: "79e636c857ed43f6cd5e5be72b36967a29f785daa63ff5b078bd34f74f44cb54" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.8.1" stack_trace: dependency: transitive description: @@ -419,6 +459,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" uuid: dependency: transitive description: diff --git a/demos/django-todolist/pubspec.yaml b/demos/django-todolist/pubspec.yaml index 34b24079..efa8e86b 100644 --- a/demos/django-todolist/pubspec.yaml +++ b/demos/django-todolist/pubspec.yaml @@ -10,7 +10,7 @@ environment: dependencies: flutter: sdk: flutter - powersync: ^1.5.5 + powersync: ^1.6.0-alpha.1 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 diff --git a/demos/supabase-anonymous-auth/lib/powersync.dart b/demos/supabase-anonymous-auth/lib/powersync.dart index 0e9e00d3..0a3730ee 100644 --- a/demos/supabase-anonymous-auth/lib/powersync.dart +++ b/demos/supabase-anonymous-auth/lib/powersync.dart @@ -1,6 +1,6 @@ // This file performs setup of the PowerSync database -import 'dart:io'; - +import 'package:universal_io/io.dart'; +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -136,8 +136,13 @@ String? getUserId() { } Future getDatabasePath() async { + const dbFilename = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (kIsWeb) { + return dbFilename; + } final dir = await getApplicationSupportDirectory(); - return join(dir.path, 'powersync-demo.db'); + return join(dir.path, dbFilename); } Future openDatabase() async { diff --git a/demos/supabase-anonymous-auth/pubspec.lock b/demos/supabase-anonymous-auth/pubspec.lock index 6766d69e..31a30fdf 100644 --- a/demos/supabase-anonymous-auth/pubspec.lock +++ b/demos/supabase-anonymous-auth/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "4.0.1" async: dependency: transitive description: @@ -65,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -98,10 +114,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -124,10 +140,10 @@ packages: dependency: transitive description: name: gotrue - sha256: "1bf6354278a98b8a1867263e94921da8a239de07e9babceab2b4e80af651a098" + sha256: a0eee21a7e8ec09e6bbd5c9a36e31e423827b575ba6fc2dd049805dcfaac5b02 url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" gtk: dependency: transitive description: @@ -152,6 +168,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" jwt_decode: dependency: transitive description: @@ -260,10 +284,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "51f0d2c554cfbc9d6a312ab35152fc77e2f0b758ce9f1a444a3a1e5b8f3c6b7f" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" path_provider_foundation: dependency: transitive description: @@ -326,7 +350,7 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.5.4" + version: "1.6.0-alpha.1" powersync_flutter_libs: dependency: "direct overridden" description: @@ -338,10 +362,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "41d6c5e0327d6c270b98b79bfed672928244af60e2856770f3eff697f9efe459" + sha256: bb6747fe2feff7f8349d563ac9d4a8f10ac2dd809bdff1e7e319321d5ea16b49 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" retry: dependency: transitive description: @@ -402,10 +426,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -503,18 +527,18 @@ packages: dependency: transitive description: name: supabase - sha256: f431753d2a4cb9dacd72c7378154f806c2b2cef23859bd9cee1add23821e874d + sha256: "2ddedf13f6dc013084569673dff7a7d540f5eacdd5b36fede8d58322e5d79c55" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "30e966b89ee61dc9de845e2d7e1c60967b3189c410d105c6d42f09b6259f4cb6" + sha256: "2d9683a15098258de137cb9182e695fa2a1a0f366c7409c2a6e6d47bc5a42be3" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.5.0" term_glyph: dependency: transitive description: @@ -539,14 +563,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_android: dependency: transitive description: @@ -559,10 +591,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_linux: dependency: transitive description: @@ -591,10 +623,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" url_launcher_windows: dependency: transitive description: @@ -639,18 +671,18 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" win32: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.4.0" xdg_directories: dependency: transitive description: @@ -669,4 +701,4 @@ packages: version: "2.0.0" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.19.0" diff --git a/demos/supabase-anonymous-auth/pubspec.yaml b/demos/supabase-anonymous-auth/pubspec.yaml index 48df77de..c9b2bab0 100644 --- a/demos/supabase-anonymous-auth/pubspec.yaml +++ b/demos/supabase-anonymous-auth/pubspec.yaml @@ -11,12 +11,13 @@ dependencies: flutter: sdk: flutter - powersync: ^1.5.5 + powersync: ^1.6.0-alpha.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 logging: ^1.2.0 sqlite_async: ^0.8.1 + universal_io: ^2.2.2 dev_dependencies: flutter_test: diff --git a/demos/supabase-edge-function-auth/lib/powersync.dart b/demos/supabase-edge-function-auth/lib/powersync.dart index 4d8e83ce..bc291497 100644 --- a/demos/supabase-edge-function-auth/lib/powersync.dart +++ b/demos/supabase-edge-function-auth/lib/powersync.dart @@ -1,6 +1,6 @@ // This file performs setup of the PowerSync database -import 'dart:io'; - +import 'package:universal_io/io.dart'; +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -139,8 +139,13 @@ String? getUserId() { } Future getDatabasePath() async { + const dbFilename = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (kIsWeb) { + return dbFilename; + } final dir = await getApplicationSupportDirectory(); - return join(dir.path, 'powersync-demo.db'); + return join(dir.path, dbFilename); } Future openDatabase() async { diff --git a/demos/supabase-edge-function-auth/pubspec.lock b/demos/supabase-edge-function-auth/pubspec.lock index 6766d69e..31a30fdf 100644 --- a/demos/supabase-edge-function-auth/pubspec.lock +++ b/demos/supabase-edge-function-auth/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "4.0.1" async: dependency: transitive description: @@ -65,6 +65,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -98,10 +114,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -124,10 +140,10 @@ packages: dependency: transitive description: name: gotrue - sha256: "1bf6354278a98b8a1867263e94921da8a239de07e9babceab2b4e80af651a098" + sha256: a0eee21a7e8ec09e6bbd5c9a36e31e423827b575ba6fc2dd049805dcfaac5b02 url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" gtk: dependency: transitive description: @@ -152,6 +168,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" jwt_decode: dependency: transitive description: @@ -260,10 +284,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "51f0d2c554cfbc9d6a312ab35152fc77e2f0b758ce9f1a444a3a1e5b8f3c6b7f" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" path_provider_foundation: dependency: transitive description: @@ -326,7 +350,7 @@ packages: path: "../../packages/powersync" relative: true source: path - version: "1.5.4" + version: "1.6.0-alpha.1" powersync_flutter_libs: dependency: "direct overridden" description: @@ -338,10 +362,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "41d6c5e0327d6c270b98b79bfed672928244af60e2856770f3eff697f9efe459" + sha256: bb6747fe2feff7f8349d563ac9d4a8f10ac2dd809bdff1e7e319321d5ea16b49 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.3" retry: dependency: transitive description: @@ -402,10 +426,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -503,18 +527,18 @@ packages: dependency: transitive description: name: supabase - sha256: f431753d2a4cb9dacd72c7378154f806c2b2cef23859bd9cee1add23821e874d + sha256: "2ddedf13f6dc013084569673dff7a7d540f5eacdd5b36fede8d58322e5d79c55" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "30e966b89ee61dc9de845e2d7e1c60967b3189c410d105c6d42f09b6259f4cb6" + sha256: "2d9683a15098258de137cb9182e695fa2a1a0f366c7409c2a6e6d47bc5a42be3" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.5.0" term_glyph: dependency: transitive description: @@ -539,14 +563,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_android: dependency: transitive description: @@ -559,10 +591,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.2.5" url_launcher_linux: dependency: transitive description: @@ -591,10 +623,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" url_launcher_windows: dependency: transitive description: @@ -639,18 +671,18 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" win32: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.4.0" xdg_directories: dependency: transitive description: @@ -669,4 +701,4 @@ packages: version: "2.0.0" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.19.0" diff --git a/demos/supabase-edge-function-auth/pubspec.yaml b/demos/supabase-edge-function-auth/pubspec.yaml index cd25689c..6529618d 100644 --- a/demos/supabase-edge-function-auth/pubspec.yaml +++ b/demos/supabase-edge-function-auth/pubspec.yaml @@ -11,12 +11,13 @@ dependencies: flutter: sdk: flutter - powersync: ^1.5.5 + powersync: ^1.6.0-alpha.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.2 path: ^1.8.3 logging: ^1.2.0 sqlite_async: ^0.8.1 + universal_io: ^2.2.2 dev_dependencies: flutter_test: diff --git a/demos/supabase-simple-chat/README.md b/demos/supabase-simple-chat/README.md index eeedeb20..b9606126 100644 --- a/demos/supabase-simple-chat/README.md +++ b/demos/supabase-simple-chat/README.md @@ -7,7 +7,7 @@ Based on the Supabase Flutter tutorial to build a simple chat app: fatalResponseCodes = [ late final PowerSyncDatabase db; Future getDatabasePath() async { + const dbFilename = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (kIsWeb) { + return dbFilename; + } final dir = await getApplicationSupportDirectory(); - return join(dir.path, 'powersync-demo.db'); + return join(dir.path, dbFilename); } bool isLoggedIn() { diff --git a/demos/supabase-simple-chat/macos/Flutter/GeneratedPluginRegistrant.swift b/demos/supabase-simple-chat/macos/Flutter/GeneratedPluginRegistrant.swift index cc3251a8..0c6fd7ff 100644 --- a/demos/supabase-simple-chat/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/demos/supabase-simple-chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import app_links import path_provider_foundation import powersync_flutter_libs import shared_preferences_foundation -import sign_in_with_apple import sqlite3_flutter_libs import url_launcher_macos @@ -18,7 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PowersyncFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "PowersyncFlutterLibsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/demos/supabase-simple-chat/pubspec.lock b/demos/supabase-simple-chat/pubspec.lock index 039ec9d0..30d14da5 100644 --- a/demos/supabase-simple-chat/pubspec.lock +++ b/demos/supabase-simple-chat/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "3ced568a5d9e309e99af71285666f1f3117bddd0bd5b3317979dccc1a40cada4" url: "https://pub.dev" source: hosted - version: "3.5.0" + version: "3.5.1" async: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "1.0.8" fake_async: dependency: transitive description: @@ -73,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -106,10 +122,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -124,18 +140,18 @@ packages: dependency: transitive description: name: functions_client - sha256: "3b157b4d3ae9e38614fd80fab76d1ef1e0e39ff3412a45de2651f27cecb9d2d2" + sha256: "48659e5c6a4bbe02659102bf6406a0cf39142202deae65aacfa78688f2e68946" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "2.2.0" gotrue: dependency: transitive description: name: gotrue - sha256: f3a47cdbc59e543f453a1ef150050cd7650fe756254ac1fcac1d2a2f6f2b5a21 + sha256: a8784341bcc08f88ba7a4b04a40a37059c7e71c315f058d45c31d09e8a951194 url: "https://pub.dev" source: hosted - version: "1.12.6" + version: "2.8.3" gtk: dependency: transitive description: @@ -144,22 +160,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" - hive_flutter: - dependency: transitive - description: - name: hive_flutter - sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc - url: "https://pub.dev" - source: hosted - version: "1.1.0" http: dependency: transitive description: @@ -188,10 +188,10 @@ packages: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.1" jwt_decode: dependency: transitive description: @@ -292,26 +292,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: "9c96da072b421e98183f9ea7464898428e764bc0ce5567f27ec8693442e72514" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.5" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -356,17 +356,17 @@ packages: dependency: transitive description: name: postgrest - sha256: f190eddc5779842dfa529fa239ec4b1025f6f968c18052ba6fffc0aecac93e6b + sha256: f1f78470a74c611811132ff12acdef9c08b3ec65b61e88161a057d6cc5fbbd83 url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "2.1.2" powersync: dependency: "direct main" description: path: "../../packages/powersync" relative: true source: path - version: "1.5.4" + version: "1.6.0-alpha.1" powersync_flutter_libs: dependency: "direct overridden" description: @@ -378,10 +378,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "2027358cdbe65d5f1770c3f768aa9adecd394de486c5dbbd2cfe19d5c6dbbc4a" + sha256: a99b7817e203c57ada746e9fe113820410cf84d9029f4310c57737aae890b0f7 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "2.2.0" retry: dependency: transitive description: @@ -402,26 +402,26 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.4.0" shared_preferences_linux: dependency: transitive description: @@ -442,10 +442,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -454,30 +454,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - sign_in_with_apple: - dependency: transitive - description: - name: sign_in_with_apple - sha256: "0975c23b9f8b30a80e27d5659a75993a093d4cb5f4eb7d23a9ccc586fea634e0" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - sign_in_with_apple_platform_interface: - dependency: transitive - description: - name: sign_in_with_apple_platform_interface - sha256: a5883edee09ed6be19de19e7d9f618a617fe41a6fa03f76d082dfb787e9ea18d - url: "https://pub.dev" - source: hosted - version: "1.0.0" - sign_in_with_apple_web: - dependency: transitive - description: - name: sign_in_with_apple_web - sha256: "44b66528f576e77847c14999d5e881e17e7223b7b0625a185417829e5306f47a" - url: "https://pub.dev" - source: hosted - version: "1.0.1" sky_engine: dependency: transitive description: flutter @@ -543,10 +519,10 @@ packages: dependency: transitive description: name: storage_client - sha256: f02d4d8967bec77767dcaf9daf24ca5b8d5a9f1cc093f14dffb77930b52589a3 + sha256: e37f1b9d40f43078d12bd2d1b6b08c2c16fbdbafc58b57bc44922da6ea3f5625 url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "2.0.2" stream_channel: dependency: transitive description: @@ -567,18 +543,18 @@ packages: dependency: transitive description: name: supabase - sha256: "1434bb9375f88f51802dadf7b99568117c434f6a9af7f8a55e5be94c8b4da7c9" + sha256: "7d2ca0499a6933b9aed2f4ff9eff4cbc4107f54006be35c42c8e1db70e99438e" url: "https://pub.dev" source: hosted - version: "1.11.11" + version: "2.2.4" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "8d68a4fa3215bc23811469fc3499c3895ebb35a2363d6edcfffaa426d5effd84" + sha256: "048d9377a76e43b95039d67e26b4bb2326fc8818df8d2f4bd63b4fdfc79f8f50" url: "https://pub.dev" source: hosted - version: "1.10.25" + version: "2.5.8" term_glyph: dependency: transitive description: @@ -611,30 +587,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: name: url_launcher - sha256: c512655380d241a337521703af62d2c122bf7b77a46ff7dd750092aa9433499c + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 + sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.3" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "75bb6fe3f60070407704282a2d295630cab232991eb52542b18347a8a941df03" + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" url_launcher_linux: dependency: transitive description: @@ -647,10 +631,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" url_launcher_platform_interface: dependency: transitive description: @@ -663,10 +647,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" url_launcher_windows: dependency: transitive description: @@ -711,50 +695,18 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" - webview_flutter: - dependency: transitive - description: - name: webview_flutter - sha256: "25e1b6e839e8cbfbd708abc6f85ed09d1727e24e08e08c6b8590d7c65c9a8932" - url: "https://pub.dev" - source: hosted - version: "4.7.0" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "3e5f4e9d818086b0d01a66fb1ff9cc72ab0cc58c71980e3d3661c5685ea0efb0" - url: "https://pub.dev" - source: hosted - version: "3.15.0" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: d937581d6e558908d7ae3dc1989c4f87b786891ab47bb9df7de548a151779d8d - url: "https://pub.dev" - source: hosted - version: "2.10.0" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: "9bf168bccdf179ce90450b5f37e36fe263f591c9338828d6bf09b6f8d0f57f86" - url: "https://pub.dev" - source: hosted - version: "3.12.0" + version: "2.4.5" win32: dependency: transitive description: name: win32 - sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.5.1" xdg_directories: dependency: transitive description: @@ -767,10 +719,10 @@ packages: dependency: transitive description: name: yet_another_json_isolate - sha256: "86fad76026c4241a32831d6c7febd8f9bded5019e2cd36c5b148499808d8307d" + sha256: e727502a2640d65b4b8a8a6cb48af9dd0cbe644ba4b3ee667c7f4afa0c1d6069 url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "2.0.0" sdks: dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.22.0" diff --git a/demos/supabase-simple-chat/pubspec.yaml b/demos/supabase-simple-chat/pubspec.yaml index e2169434..d0581a51 100644 --- a/demos/supabase-simple-chat/pubspec.yaml +++ b/demos/supabase-simple-chat/pubspec.yaml @@ -19,7 +19,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ^3.2.0 + sdk: ^3.2.3 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -35,12 +35,13 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 - supabase_flutter: ^1.10.25 + supabase_flutter: ^2.0.2 timeago: ^3.6.0 - powersync: ^1.5.5 + powersync: ^1.6.0-alpha.1 path_provider: ^2.1.1 path: ^1.8.3 logging: ^1.2.0 + universal_io: ^2.2.2 dev_dependencies: flutter_test: diff --git a/demos/supabase-todolist/.gitignore b/demos/supabase-todolist/.gitignore index 0b04140a..1a825b5b 100644 --- a/demos/supabase-todolist/.gitignore +++ b/demos/supabase-todolist/.gitignore @@ -47,4 +47,4 @@ app.*.map.json .tool-versions # secrets -app_config.dart +app_config.dart \ No newline at end of file diff --git a/demos/supabase-todolist/README.md b/demos/supabase-todolist/README.md index da957598..b6e281c7 100644 --- a/demos/supabase-todolist/README.md +++ b/demos/supabase-todolist/README.md @@ -7,7 +7,7 @@ Demo app demonstrating use of the PowerSync SDK for Flutter together with Supaba Ensure you have [melos](https://melos.invertase.dev/~melos-latest/getting-started) installed. 1. `cd demos/supabase-todolist` -2. `melos bootstrap` +2. `melos prepare` 3. `cp lib/app_config_template.dart lib/app_config.dart` 4. Insert your Supabase and PowerSync project credentials into `lib/app_config.dart` (See instructions below) 5. `flutter run` diff --git a/demos/supabase-todolist/ios/Podfile.lock b/demos/supabase-todolist/ios/Podfile.lock index 2b36a5a6..3440e3db 100644 --- a/demos/supabase-todolist/ios/Podfile.lock +++ b/demos/supabase-todolist/ios/Podfile.lock @@ -72,13 +72,13 @@ SPEC CHECKSUMS: app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 camera_avfoundation: 759172d1a77ae7be0de08fc104cfb79738b8a59e Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 powersync-sqlite-core: 4c38c8f470f6dca61346789fd5436a6826d1e3dd powersync_flutter_libs: 5d6b132a398de442c0853a8b14bfbb62cd4ff5a1 - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b - url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe PODFILE CHECKSUM: f7b3cb7384a2d5da4b22b090e1f632de7f377987 diff --git a/demos/supabase-todolist/lib/migrations/fts_setup.dart b/demos/supabase-todolist/lib/migrations/fts_setup.dart index 063d8484..9754e44e 100644 --- a/demos/supabase-todolist/lib/migrations/fts_setup.dart +++ b/demos/supabase-todolist/lib/migrations/fts_setup.dart @@ -1,5 +1,5 @@ import 'package:powersync/powersync.dart'; -import 'package:sqlite_async/sqlite_async.dart'; +import 'package:powersync/sqlite_async.dart'; import 'helpers.dart'; import '../models/schema.dart'; diff --git a/demos/supabase-todolist/lib/models/todo_item.dart b/demos/supabase-todolist/lib/models/todo_item.dart index 5e15f3f8..1cd43de0 100644 --- a/demos/supabase-todolist/lib/models/todo_item.dart +++ b/demos/supabase-todolist/lib/models/todo_item.dart @@ -1,7 +1,7 @@ import 'package:powersync_flutter_demo/models/schema.dart'; import '../powersync.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; /// TodoItem represents a result row of a query on "todos". /// diff --git a/demos/supabase-todolist/lib/models/todo_list.dart b/demos/supabase-todolist/lib/models/todo_list.dart index af33908f..86170e02 100644 --- a/demos/supabase-todolist/lib/models/todo_list.dart +++ b/demos/supabase-todolist/lib/models/todo_list.dart @@ -1,5 +1,5 @@ import 'package:powersync/powersync.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; import './todo_item.dart'; import '../powersync.dart'; diff --git a/demos/supabase-todolist/lib/powersync.dart b/demos/supabase-todolist/lib/powersync.dart index aaf952b4..10c5966a 100644 --- a/demos/supabase-todolist/lib/powersync.dart +++ b/demos/supabase-todolist/lib/powersync.dart @@ -1,4 +1,5 @@ // This file performs setup of the PowerSync database +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -145,8 +146,13 @@ String? getUserId() { } Future getDatabasePath() async { + const dbFilename = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (kIsWeb) { + return dbFilename; + } final dir = await getApplicationSupportDirectory(); - return join(dir.path, 'powersync-demo.db'); + return join(dir.path, dbFilename); } Future openDatabase() async { diff --git a/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart b/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart index a4ef1dce..915449dd 100644 --- a/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart +++ b/demos/supabase-todolist/lib/widgets/fts_search_delegate.dart @@ -89,6 +89,7 @@ class FtsSearchDelegate extends SearchDelegate { } Future _search() async { + if (query.isEmpty) return []; List listsSearchResults = await fts_helpers.search(query, 'lists'); List todoItemsSearchResults = await fts_helpers.search(query, 'todos'); List formattedListResults = listsSearchResults diff --git a/demos/supabase-todolist/lib/widgets/query_widget.dart b/demos/supabase-todolist/lib/widgets/query_widget.dart index fed1bd78..426c7060 100644 --- a/demos/supabase-todolist/lib/widgets/query_widget.dart +++ b/demos/supabase-todolist/lib/widgets/query_widget.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; import './resultset_table.dart'; import '../powersync.dart'; diff --git a/demos/supabase-todolist/lib/widgets/resultset_table.dart b/demos/supabase-todolist/lib/widgets/resultset_table.dart index b1606adf..f348e4ff 100644 --- a/demos/supabase-todolist/lib/widgets/resultset_table.dart +++ b/demos/supabase-todolist/lib/widgets/resultset_table.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; /// Stateless DataTable rendering results from a SQLite query class ResultSetTable extends StatelessWidget { diff --git a/demos/supabase-todolist/pubspec.lock b/demos/supabase-todolist/pubspec.lock index 2a07150d..29a9d070 100644 --- a/demos/supabase-todolist/pubspec.lock +++ b/demos/supabase-todolist/pubspec.lock @@ -5,10 +5,34 @@ packages: dependency: transitive description: name: app_links - sha256: "0fd41f0501f131d931251e0942ac63d6216096a0052aeca037915c2c1deeb121" + sha256: a9905d6a60e814503fabc7523a9ed161b812d7ca69c99ad8ceea14279dc4f06b url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.1.3" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: "567139eca3ca9fb113f2082f3aaa75a26f30f0ebdbe5fa7f09a3913c5bebd630" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "58cff6f11df59b0e514dd5e4a61e988348ad5662f0e75d45d4e214ebea55c94c" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: "74586ed5f3c4786341e82a0fa43c39ec3f13108a550f74e80d8bf68aa11349d1" + url: "https://pub.dev" + source: hosted + version: "1.0.3" archive: dependency: transitive description: @@ -121,6 +145,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "97f46c25b480aad74f7cc2ad7ccba2c5c6f08d008e68f95c1077286ce243d0e6" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "9666ee14536778474072245ed5cba07db81ae8eb5de3b7bf4a2d1e2c49696092" + url: "https://pub.dev" + source: hosted + version: "1.1.2" ffi: dependency: transitive description: @@ -180,18 +220,18 @@ packages: dependency: transitive description: name: functions_client - sha256: a70b0dd9a1c35d05d1141557f7e49ffe4de5f450ffde31755a9eeeadca03b8ee + sha256: "48659e5c6a4bbe02659102bf6406a0cf39142202deae65aacfa78688f2e68946" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" gotrue: dependency: transitive description: name: gotrue - sha256: c9c984f088320a5c5e87c7a34571e3de3982cca4cbd8b978e59d36baf748edfb + sha256: "65c8c47afb8230218bc295e6edcb948b117e39801f91c4a4bcb94dfd26b57134" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.8.1" gtk: dependency: transitive description: @@ -224,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" jwt_decode: dependency: transitive description: @@ -332,18 +380,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: bca87b0165ffd7cdb9cad8edd22d18d2201e886d9a9f19b4fb3452ea7df3a72a url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.6" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -380,10 +428,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -396,24 +444,24 @@ packages: dependency: transitive description: name: postgrest - sha256: "9a3b590cf123f8d323b6a918702e037f037027d12a01902f9dc6ee38fdc05d6c" + sha256: f1f78470a74c611811132ff12acdef9c08b3ec65b61e88161a057d6cc5fbbd83 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" powersync: dependency: "direct main" description: path: "../../packages/powersync" relative: true source: path - version: "1.5.4" + version: "1.6.0-alpha.1" powersync_attachments_helper: dependency: "direct main" description: path: "../../packages/powersync_attachments_helper" relative: true source: path - version: "0.5.0" + version: "0.6.0-alpha.1" powersync_flutter_libs: dependency: "direct overridden" description: @@ -425,10 +473,10 @@ packages: dependency: transitive description: name: realtime_client - sha256: "492a1ab568b3812cb345aad8dd09b3936877edba81a6ab6f5fdf365c155797e1" + sha256: cd44fa21407a2e217d674f1c1a33b36c49ad0d8aea0349bf5b66594db06c80fb url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.1.0" retry: dependency: transitive description: @@ -457,18 +505,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + sha256: "93d0ec9dd902d85f326068e6a899487d1f65ffcd5798721a95330b26c8131577" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.4.0" shared_preferences_linux: dependency: transitive description: @@ -566,10 +614,10 @@ packages: dependency: transitive description: name: storage_client - sha256: bf5589d5de61a2451edb1b8960a0e673d4bb5c42ecc4dddf7c051a93789ced34 + sha256: e37f1b9d40f43078d12bd2d1b6b08c2c16fbdbafc58b57bc44922da6ea3f5625 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" stream_channel: dependency: transitive description: @@ -598,18 +646,18 @@ packages: dependency: transitive description: name: supabase - sha256: ef407187b18c440f4a5c3f3cf30eb5cc1daadd4ff5616febf445a37e0e0ed34e + sha256: "073aabf6a9f6ada2ebb77082222e1104949afb9f7f181017d0643d99bda0efe3" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.2" supabase_flutter: dependency: "direct main" description: name: supabase_flutter - sha256: "6a77bd6ef6dc451bb2561de0334d68d620b84d7df1de1448dd7962ed5d1a79ea" + sha256: ae56c20924fadd62f0a83f0570c22cec85f4b093768fbd0e049c4e1741a109a7 url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.6" term_glyph: dependency: transitive description: @@ -634,30 +682,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_io: + dependency: "direct main" + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.3.0" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + sha256: ceb2625f0c24ade6ef6778d1de0b2e44f2db71fded235eb52295247feba8c5cf url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.3" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" url: "https://pub.dev" source: hosted - version: "6.2.5" + version: "6.3.0" url_launcher_linux: dependency: transitive description: @@ -670,10 +726,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" url_launcher_platform_interface: dependency: transitive description: @@ -742,10 +798,10 @@ packages: dependency: transitive description: name: win32 - sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb" + sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.5.1" xdg_directories: dependency: transitive description: diff --git a/demos/supabase-todolist/pubspec.yaml b/demos/supabase-todolist/pubspec.yaml index 4abf8834..54bf6304 100644 --- a/demos/supabase-todolist/pubspec.yaml +++ b/demos/supabase-todolist/pubspec.yaml @@ -10,15 +10,16 @@ environment: dependencies: flutter: sdk: flutter - powersync_attachments_helper: ^0.5.1 - powersync: ^1.5.5 + powersync_attachments_helper: ^0.6.0-alpha.1 + powersync: ^1.6.0-alpha.1 path_provider: ^2.1.1 supabase_flutter: ^2.0.1 path: ^1.8.3 logging: ^1.2.0 - sqlite_async: ^0.8.1 camera: ^0.10.5+7 image: ^4.1.3 + universal_io: ^2.2.2 + sqlite_async: ^0.8.1 dev_dependencies: flutter_test: diff --git a/demos/supabase-todolist/web/favicon.png b/demos/supabase-todolist/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/demos/supabase-todolist/web/favicon.png differ diff --git a/demos/supabase-todolist/web/icons/Icon-192.png b/demos/supabase-todolist/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/demos/supabase-todolist/web/icons/Icon-192.png differ diff --git a/demos/supabase-todolist/web/icons/Icon-512.png b/demos/supabase-todolist/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/demos/supabase-todolist/web/icons/Icon-512.png differ diff --git a/demos/supabase-todolist/web/icons/Icon-maskable-192.png b/demos/supabase-todolist/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/demos/supabase-todolist/web/icons/Icon-maskable-192.png differ diff --git a/demos/supabase-todolist/web/icons/Icon-maskable-512.png b/demos/supabase-todolist/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/demos/supabase-todolist/web/icons/Icon-maskable-512.png differ diff --git a/demos/supabase-todolist/web/index.html b/demos/supabase-todolist/web/index.html new file mode 100644 index 00000000..b3b0c490 --- /dev/null +++ b/demos/supabase-todolist/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + supabase_todolist + + + + + + + + + + diff --git a/demos/supabase-todolist/web/manifest.json b/demos/supabase-todolist/web/manifest.json new file mode 100644 index 00000000..9dcf6fe4 --- /dev/null +++ b/demos/supabase-todolist/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "supabase_todolist", + "short_name": "supabase_todolist", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/melos.yaml b/melos.yaml index aa282e5f..a50b9c3a 100644 --- a/melos.yaml +++ b/melos.yaml @@ -9,25 +9,40 @@ ide: intellij: false scripts: + prepare: melos bootstrap && dart ./scripts/compile_webworker.dart && dart ./scripts/init_sqlite_wasm.dart && dart ./scripts/init_powersync_core_binary.dart + + analyze:demos: + description: Analyze Dart code in demos. + run: dart analyze demos --fatal-infos + + analyze:packages: + description: Analyze Dart code in packages. + run: dart analyze packages --fatal-infos + + analyze:packages:pana: + description: Analyze Dart packages with Pana + exec: flutter pub global run pana --no-warning --exit-code-threshold 10 + packageFilters: + noPrivate: true + + compile:webworker: + description: Compile Javascript web worker distributable + exec: dart run powersync_web_worker:compile_webworker + packageFilters: + scope: + - powersync_web_worker + format: description: Format Dart code. run: dart format . - format:check:packages: - description: Check formatting of Dart code in packages. - run: dart format --output none --set-exit-if-changed packages - format:check:demos: description: Check formatting of Dart code in demos. run: dart format --output none --set-exit-if-changed demos - analyze:packages: - description: Analyze Dart code in packages. - run: dart analyze packages --fatal-infos - - analyze:demos: - description: Analyze Dart code in demos. - run: dart analyze demos --fatal-infos + format:check:packages: + description: Check formatting of Dart code in packages. + run: dart format --output none --set-exit-if-changed packages test: description: Run tests in a specific package. @@ -41,3 +56,16 @@ scripts: # as they could change the behaviour of how tests filter packages. env: MELOS_TEST: true + + test:web: + description: Run web tests in a specific package. + run: dart test -p chrome + exec: + concurrency: 1 + packageFilters: + dirExists: + - test + env: + MELOS_TEST: true + + update:wasm: dart run scripts/init_sqlite_wasm.dart diff --git a/packages/powersync/CHANGELOG.md b/packages/powersync/CHANGELOG.md index b1c0cfe6..e1199e99 100644 --- a/packages/powersync/CHANGELOG.md +++ b/packages/powersync/CHANGELOG.md @@ -1,6 +1,13 @@ +## 1.6.0-alpha.1 + +- Added support for client parameters when connecting. +- Fix watch query parameter `triggerOnTables` to prepend powersync view names. +- Upgrade dependency `sqlite_async` to version 0.8.1. +- Fix issue where `hasSynced` is cleared when offline. + ## 1.5.5 - - Fix issue where `hasSynced` is cleared when offline. +- Fix issue where `hasSynced` is cleared when offline. ## 1.5.4 @@ -39,6 +46,24 @@ - Introduces the use of the `powersync-sqlite-core` native extension. This is our common Rust core which means all PowerSync SDKs now use the same core logic for PowerSync functionality, improving maintainability and support. - Added a new package dependency on `powersync_flutter_libs` for loading the extension. +## 1.3.0-alpha.9 + +- Updated sqlite_async to use Navigator locks for limiting sync stream implementations in multiple tabs + +## 1.3.0-alpha.8 + +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **DOCS**: update readme and getting started (#51). +- Updates and uses the latest `sqlite_async` package. + +## 1.3.0-alpha.7 + +- Updates and uses the latest `sqlite_async` alpha. + +## 1.3.0-alpha.6 + +- Fix `Bad state: Future already completed` error when calling `disconnectAndClear()`. + ## 1.3.1 - Fix "Checksum mismatch" issue when calling `PowerSyncDatabase.connect` multiple times. @@ -47,6 +72,28 @@ - Add `crudThrottleTime` option to arguments when running `PowerSyncDatabase.connect` to set throttle time for crud operations. +## 1.3.0-alpha.5 + +- Update `sqlite_async.dart` dependency +- Fix issue where sync stream connection would fail to connect https://github.com/powersync-ja/powersync.dart/issues/11 + +## 1.3.0-alpha.4 + +- Merge master branch in and resolve conflicts + +## 1.3.0-alpha.3 + +- Fixed issue where disconnectAndClear would prevent subsequent sync connection on native platforms and would fail to clear the database on web. + +## 1.3.0-alpha.2 + +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **DOCS**: update readme and getting started (#51). + +## 1.3.0-alpha.1 + +- Added initial support for Web platform. + ## 1.2.2 - Deprecate DevConnector and related diff --git a/packages/powersync/README.md b/packages/powersync/README.md index be7e50c3..3d576be9 100644 --- a/packages/powersync/README.md +++ b/packages/powersync/README.md @@ -4,9 +4,9 @@ # PowerSync SDK for Dart/Flutter -*[PowerSync](https://www.powersync.com) is a Postgres-SQLite sync layer, which helps developers to create local-first real-time reactive apps that work seamlessly both online and offline.* +_[PowerSync](https://www.powersync.com) is a Postgres-SQLite sync layer, which helps developers to create local-first real-time reactive apps that work seamlessly both online and offline._ -This package (`powersync`) is the PowerSync client SDK for Dart/Flutter. +This package (`powersync`) is the PowerSync client SDK for Dart/Flutter. See a summary of features [here](https://docs.powersync.com/client-sdk-references/flutter). @@ -20,6 +20,76 @@ flutter pub add powersync Our [full SDK reference](https://docs.powersync.com/client-sdk-references/flutter) contains everything you need to know to get started implementing PowerSync in your project. +## **_ Web support - Open alpha _** + +Web support is currently in an alpha release. This Readme has been updated to reflect updates that are currently only relevant to this alpha release. + +### Demo app + +The easiest way to test out the alpha is to run the [Supabase Todo-List](./demos/supabase-todolist) demo app: + +1. Checkout [this repo's](https://github.com/powersync-ja/powersync.dart/tree/alpha-release) `alpha_release` branch. + +- Note: If you are an existing user updating to the latest code after a git pull, run `melos exec 'flutter pub upgrade'` in the repo's root and make sure it succeeds. + +2. Run `melos prepare` in the repo's root +3. cd into the `demos/supabase-todolist` folder +4. If you haven’t yet: `cp lib/app_config_template.dart lib/app_config.dart` (optionally update this config with your own Supabase and PowerSync project details). +5. Run `flutter run -d chrome` + +### Installing PowerSync in your own project + +Install the latest alpha version of the package, for example: + +``` +flutter pub add powersync:'^1.3.0-alpha.1' +``` + +The latest prerelease version can be found [here](https://pub.dev/packages/powersync/versions). + +### Additional config + +Web support requires `sqlite3.wasm` and `powersync_db.worker.js` assets to be served from the web application. This is typically achieved by placing the files in the project `web` directory. + +- `sqlite3.wasm` can be found [here](https://github.com/powersync-ja/sqlite3.dart/releases/download/v0.1.0/sqlite3.wasm) +- `powersync_db.worker.js` can be found in the repo's [releases](https://github.com/powersync-ja/powersync.dart/releases) page. + +Currently the Drift SQLite library is used under the hood for DB connections. See [here](https://drift.simonbinder.eu/web/#getting-started) for detailed compatibility +and setup notes. + +The same code is used for initializing native and web `PowerSyncDatabase` clients. + +### Limitations + +The API for web is essentially the same as for native platforms. Some features within `PowerSyncDatabase` clients are not available. + +Multiple tab support is not yet available. Using multiple tabs will break. + +#### Imports + +Flutter Web does not support importing directly from `sqlite3.dart` as it uses `dart:ffi`. + +Change imports from + +```Dart +import 'package/powersync/sqlite3.dart` +``` + +to + +```Dart +import 'package/powersync/sqlite3_common.dart' +``` + +In code which needs to run on the Web platform. Isolated native specific code can still import from `sqlite3.dart`. + +#### Database connections + +Web DB connections do not support concurrency. A single DB connection is used. `readLock` and `writeLock` contexts do not +implement checks for preventing writable queries in read connections and vice-versa. + +Direct access to the synchronous `CommonDatabase` (`sqlite.Database` equivalent for web) connection is not available. `computeWithDatabase` is not available on web. + # Changelog A changelog for this SDK is available [here](https://releases.powersync.com/announcements/flutter-client-sdk). diff --git a/packages/powersync/example/getting_started.dart b/packages/powersync/example/getting_started.dart index cf44cbc4..348b3021 100644 --- a/packages/powersync/example/getting_started.dart +++ b/packages/powersync/example/getting_started.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:powersync/powersync.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart'; @@ -25,8 +26,13 @@ class BackendConnector extends PowerSyncBackendConnector { } openDatabase() async { - final dir = await getApplicationSupportDirectory(); - final path = join(dir.path, 'powersync-dart.db'); + var path = 'powersync-demo.db'; + // getApplicationSupportDirectory is not supported on Web + if (!kIsWeb) { + final dir = await getApplicationSupportDirectory(); + path = join(dir.path, 'powersync-dart.db'); + } + // Setup the database. db = PowerSyncDatabase(schema: schema, path: path); await db.initialize(); diff --git a/packages/powersync/lib/powersync.dart b/packages/powersync/lib/powersync.dart index 05963fb9..d931d4f4 100644 --- a/packages/powersync/lib/powersync.dart +++ b/packages/powersync/lib/powersync.dart @@ -5,6 +5,7 @@ library; export 'src/connector.dart'; export 'src/crud.dart'; +export 'src/database/powersync_database.dart'; export 'src/exceptions.dart'; export 'src/log.dart'; export 'src/open_factory.dart'; diff --git a/packages/powersync/lib/src/bucket_storage.dart b/packages/powersync/lib/src/bucket_storage.dart index a797914b..2781f1d4 100644 --- a/packages/powersync/lib/src/bucket_storage.dart +++ b/packages/powersync/lib/src/bucket_storage.dart @@ -2,39 +2,37 @@ import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:sqlite_async/mutex.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; -import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:powersync/sqlite_async.dart'; +import 'package:powersync/sqlite3_common.dart'; import 'crud.dart'; -import 'database_utils.dart'; import 'schema_logic.dart'; import 'sync_types.dart'; const compactOperationInterval = 1000; class BucketStorage { - final CommonDatabase _internalDb; - final Mutex mutex; + final SqliteConnection _internalDb; bool _hasCompletedSync = false; bool _pendingBucketDeletes = false; int _compactCounter = compactOperationInterval; - BucketStorage(CommonDatabase db, {required this.mutex}) : _internalDb = db { + BucketStorage(SqliteConnection db) : _internalDb = db { _init(); } _init() {} // Use only for read statements - sqlite.ResultSet select(String query, [List parameters = const []]) { - return _internalDb.select(query, parameters); + Future select(String query, + [List parameters = const []]) async { + return await _internalDb.execute(query, parameters); } void startSession() {} - List getBucketStates() { - final rows = select( + Future> getBucketStates() async { + final rows = await select( 'SELECT name as bucket, cast(last_op as TEXT) as op_id FROM ps_buckets WHERE pending_delete = 0'); return [ for (var row in rows) @@ -43,8 +41,9 @@ class BucketStorage { } Future streamOp(String op) async { - await writeTransaction((db) { - db.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', + await writeTransaction((tx) async { + await tx.execute( + 'INSERT INTO powersync_operations(op, data) VALUES(?, ?)', ['stream', op]); }); } @@ -52,11 +51,11 @@ class BucketStorage { Future saveSyncData(SyncDataBatch batch) async { var count = 0; - await writeTransaction((db) { + await writeTransaction((tx) async { for (var b in batch.buckets) { count += b.data.length; - _updateBucket2( - db, + await _updateBucket2( + tx, jsonEncode({ 'buckets': [b] })); @@ -65,8 +64,8 @@ class BucketStorage { _compactCounter += count; } - void _updateBucket2(CommonDatabase db, String json) { - db.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', + Future _updateBucket2(SqliteWriteContext tx, String json) async { + await tx.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', ['save', json]); } @@ -77,19 +76,20 @@ class BucketStorage { } Future deleteBucket(String bucket) async { - await writeTransaction((db) { - db.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', + await writeTransaction((tx) async { + await tx.execute( + 'INSERT INTO powersync_operations(op, data) VALUES(?, ?)', ['delete_bucket', bucket]); }); _pendingBucketDeletes = true; } - bool hasCompletedSync() { + Future hasCompletedSync() async { if (_hasCompletedSync) { return true; } - final rs = select( + final rs = await select( "SELECT name, last_applied_op FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1"); if (rs.isNotEmpty) { _hasCompletedSync = true; @@ -100,22 +100,23 @@ class BucketStorage { Future syncLocalDatabase( Checkpoint checkpoint) async { - final r = validateChecksums(checkpoint); + final r = await validateChecksums(checkpoint); if (!r.checkpointValid) { for (String b in r.checkpointFailures ?? []) { - deleteBucket(b); + await deleteBucket(b); } return r; } final bucketNames = [for (final c in checkpoint.checksums) c.bucket]; - await writeTransaction((db) { - db.execute( + await writeTransaction((tx) async { + await tx.execute( "UPDATE ps_buckets SET last_op = ? WHERE name IN (SELECT json_each.value FROM json_each(?))", [checkpoint.lastOpId, jsonEncode(bucketNames)]); if (checkpoint.writeCheckpoint != null) { - db.execute("UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'", + await tx.execute( + "UPDATE ps_buckets SET last_op = ? WHERE name = '\$local'", [checkpoint.writeCheckpoint]); } }); @@ -131,10 +132,11 @@ class BucketStorage { } Future updateObjectsFromBuckets(Checkpoint checkpoint) async { - return writeTransaction((db) { - db.execute("INSERT INTO powersync_operations(op, data) VALUES(?, ?)", + return writeTransaction((tx) async { + await tx.execute( + "INSERT INTO powersync_operations(op, data) VALUES(?, ?)", ['sync_local', '']); - final rs = db.select('SELECT last_insert_rowid() as result'); + final rs = await tx.execute('SELECT last_insert_rowid() as result'); final result = rs[0]['result']; if (result == 1) { return true; @@ -145,8 +147,9 @@ class BucketStorage { }); } - SyncLocalDatabaseResult validateChecksums(Checkpoint checkpoint) { - final rs = select("SELECT powersync_validate_checkpoint(?) as result", + Future validateChecksums( + Checkpoint checkpoint) async { + final rs = await select("SELECT powersync_validate_checkpoint(?) as result", [jsonEncode(checkpoint)]); final result = jsonDecode(rs[0]['result']); if (result['valid']) { @@ -179,10 +182,10 @@ class BucketStorage { // ignore: unused_element Future _compactWal() async { try { - await writeTransaction((db) { - db.select('PRAGMA wal_checkpoint(TRUNCATE)'); + await writeTransaction((tx) async { + await tx.execute('PRAGMA wal_checkpoint(TRUNCATE)'); }); - } on sqlite.SqliteException catch (e) { + } on SqliteException catch (e) { // Ignore SQLITE_BUSY if (e.resultCode == 5) { // Ignore @@ -195,8 +198,9 @@ class BucketStorage { Future _deletePendingBuckets() async { if (_pendingBucketDeletes) { // Executed once after start-up, and again when there are pending deletes. - await writeTransaction((db) { - db.execute('INSERT INTO powersync_operations(op, data) VALUES (?, ?)', + await writeTransaction((tx) async { + await tx.execute( + 'INSERT INTO powersync_operations(op, data) VALUES (?, ?)', ['delete_pending_buckets', '']); }); _pendingBucketDeletes = false; @@ -208,8 +212,9 @@ class BucketStorage { return; } - await writeTransaction((db) { - db.execute('INSERT INTO powersync_operations(op, data) VALUES (?, ?)', + await writeTransaction((tx) async { + await tx.execute( + 'INSERT INTO powersync_operations(op, data) VALUES (?, ?)', ['clear_remove_ops', '']); }); _compactCounter = 0; @@ -221,14 +226,14 @@ class BucketStorage { Future updateLocalTarget( Future Function() checkpointCallback) async { - final rs1 = select( - 'SELECT target_op FROM ps_buckets WHERE name = \'\$local\' AND target_op = $maxOpId'); + final rs1 = await select( + 'SELECT CAST(target_op AS TEXT) FROM ps_buckets WHERE name = \'\$local\' AND target_op = $maxOpId'); if (rs1.isEmpty) { // Nothing to update return false; } - final rs = - select('SELECT seq FROM sqlite_sequence WHERE name = \'ps_crud\''); + final rs = await select( + 'SELECT seq FROM sqlite_sequence WHERE name = \'ps_crud\''); if (rs.isEmpty) { // Nothing to update return false; @@ -236,13 +241,13 @@ class BucketStorage { int seqBefore = rs.first['seq']; var opId = await checkpointCallback(); - return await writeTransaction((tx) { - final anyData = tx.select('SELECT 1 FROM ps_crud LIMIT 1'); + return await writeTransaction((tx) async { + final anyData = await tx.execute('SELECT 1 FROM ps_crud LIMIT 1'); if (anyData.isNotEmpty) { return false; } - final rs = - tx.select('SELECT seq FROM sqlite_sequence WHERE name = \'ps_crud\''); + final rs = await tx + .execute('SELECT seq FROM sqlite_sequence WHERE name = \'ps_crud\''); assert(rs.isNotEmpty); int seqAfter = rs.first['seq']; @@ -251,26 +256,26 @@ class BucketStorage { return false; } - tx.select( + await tx.execute( "UPDATE ps_buckets SET target_op = ? WHERE name='\$local'", [opId]); return true; }); } - bool hasCrud() { - final anyData = select('SELECT 1 FROM ps_crud LIMIT 1'); + Future hasCrud() async { + final anyData = await select('SELECT 1 FROM ps_crud LIMIT 1'); return anyData.isNotEmpty; } /// For tests only. Others should use the version on PowerSyncDatabase. - CrudBatch? getCrudBatch({int limit = 100}) { - if (!hasCrud()) { + Future getCrudBatch({int limit = 100}) async { + if (!(await hasCrud())) { return null; } final rows = - select('SELECT * FROM ps_crud ORDER BY id ASC LIMIT ?', [limit]); + await select('SELECT * FROM ps_crud ORDER BY id ASC LIMIT ?', [limit]); List all = []; for (var row in rows) { all.add(CrudEntry.fromRow(row)); @@ -283,14 +288,15 @@ class BucketStorage { crud: all, haveMore: true, complete: ({String? writeCheckpoint}) async { - await writeTransaction((db) { - db.execute('DELETE FROM ps_crud WHERE id <= ?', [last.clientId]); + await writeTransaction((tx) async { + await tx + .execute('DELETE FROM ps_crud WHERE id <= ?', [last.clientId]); if (writeCheckpoint != null && - db.select('SELECT 1 FROM ps_crud LIMIT 1').isEmpty) { - db.execute( + (await tx.execute('SELECT 1 FROM ps_crud LIMIT 1')).isEmpty) { + await tx.execute( 'UPDATE ps_buckets SET target_op = $writeCheckpoint WHERE name=\'\$local\''); } else { - db.execute( + await tx.execute( 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); } }); @@ -302,12 +308,9 @@ class BucketStorage { /// is assumed that multiple functions on this instance won't be called /// concurrently. Future writeTransaction( - FutureOr Function(CommonDatabase tx) callback, + Future Function(SqliteWriteContext tx) callback, {Duration? lockTimeout}) async { - return mutex.lock(() async { - final r = await asyncDirectTransaction(_internalDb, callback); - return r; - }); + return _internalDb.writeTransaction(callback, lockTimeout: lockTimeout); } } diff --git a/packages/powersync/lib/src/connector.dart b/packages/powersync/lib/src/connector.dart index 1ba0faba..f8d58460 100644 --- a/packages/powersync/lib/src/connector.dart +++ b/packages/powersync/lib/src/connector.dart @@ -1,9 +1,6 @@ import 'dart:convert'; -import 'dart:io'; - import 'package:http/http.dart' as http; - -import 'powersync_database.dart'; +import 'database/powersync_database.dart'; /// Implement this to connect an app backend. /// @@ -263,7 +260,7 @@ class DevConnector extends PowerSyncBackendConnector { token: parsed['data']['token'], userId: parsed['data']['user_id'])); } else { - throw HttpException(res.reasonPhrase ?? 'Request failed', uri: uri); + throw http.ClientException(res.reasonPhrase ?? 'Request failed', uri); } } @@ -281,7 +278,7 @@ class DevConnector extends PowerSyncBackendConnector { clearDevToken(); } if (res.statusCode != 200) { - throw HttpException(res.reasonPhrase ?? 'Request failed', uri: uri); + throw http.ClientException(res.reasonPhrase ?? 'Request failed', uri); } return PowerSyncCredentials.fromJson(jsonDecode(res.body)['data']); @@ -315,9 +312,8 @@ class DevConnector extends PowerSyncBackendConnector { } if (response.statusCode != 200) { - throw HttpException( - response.reasonPhrase ?? "Failed due to server error.", - uri: uri); + throw http.ClientException( + response.reasonPhrase ?? "Failed due to server error.", uri); } final body = jsonDecode(response.body); diff --git a/packages/powersync/lib/src/crud.dart b/packages/powersync/lib/src/crud.dart index 10785280..3ca4c288 100644 --- a/packages/powersync/lib/src/crud.dart +++ b/packages/powersync/lib/src/crud.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; /// A batch of client-side changes. class CrudBatch { diff --git a/packages/powersync/lib/src/database/native/native_powersync_database.dart b/packages/powersync/lib/src/database/native/native_powersync_database.dart new file mode 100644 index 00000000..0bf859ed --- /dev/null +++ b/packages/powersync/lib/src/database/native/native_powersync_database.dart @@ -0,0 +1,387 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'package:meta/meta.dart'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:powersync/src/abort_controller.dart'; +import 'package:powersync/src/bucket_storage.dart'; +import 'package:powersync/src/connector.dart'; +import 'package:powersync/src/database/powersync_database.dart'; +import 'package:powersync/src/database/powersync_db_mixin.dart'; +import 'package:powersync/src/isolate_completer.dart'; +import 'package:powersync/src/log.dart'; +import 'package:powersync/src/log_internal.dart'; +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'package:powersync/src/open_factory/native/native_open_factory.dart'; +import 'package:powersync/src/schema.dart'; +import 'package:powersync/src/schema_logic.dart'; +import 'package:powersync/src/streaming_sync.dart'; +import 'package:powersync/src/sync_status.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// A PowerSync managed database. +/// +///Native implementation for [PowerSyncDatabase] +/// +/// Use one instance per database file. +/// +/// Use [PowerSyncDatabase.connect] to connect to the PowerSync service, +/// to keep the local database in sync with the remote database. +/// +/// All changes to local tables are automatically recorded, whether connected +/// or not. Once connected, the changes are uploaded. +class PowerSyncDatabaseImpl + with SqliteQueries, PowerSyncDatabaseMixin + implements PowerSyncDatabase { + @override + Schema schema; + + @override + SqliteDatabase database; + + @override + @protected + late Future isInitialized; + + @override + + /// The Logger used by this [PowerSyncDatabase]. + /// + /// The default is [autoLogger], which logs to the console in debug builds. + /// Use [debugLogger] to always log to the console. + /// Use [attachedLogger] to propagate logs to [Logger.root] for custom logging. + late final Logger logger; + + /// Open a [PowerSyncDatabase]. + /// + /// Only a single [PowerSyncDatabase] per [path] should be opened at a time. + /// + /// The specified [schema] is used for the database. + /// + /// A connection pool is used by default, allowing multiple concurrent read + /// transactions, and a single concurrent write transaction. Write transactions + /// do not block read transactions, and read transactions will see the state + /// from the last committed write transaction. + /// + /// A maximum of [maxReaders] concurrent read transactions are allowed. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabaseImpl( + {required Schema schema, + required String path, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger, + @Deprecated("Use [PowerSyncDatabase.withFactory] instead.") + // ignore: deprecated_member_use_from_same_package + SqliteConnectionSetup? sqliteSetup}) { + // ignore: deprecated_member_use_from_same_package + DefaultSqliteOpenFactory factory = + // ignore: deprecated_member_use_from_same_package + PowerSyncOpenFactory(path: path, sqliteSetup: sqliteSetup); + return PowerSyncDatabaseImpl.withFactory(factory, + schema: schema, logger: logger); + } + + /// Open a [PowerSyncDatabase] with a [PowerSyncOpenFactory]. + /// + /// The factory determines which database file is opened, as well as any + /// additional logic to run inside the database isolate before or after opening. + /// + /// Subclass [PowerSyncOpenFactory] to add custom logic to this process. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabaseImpl.withFactory( + DefaultSqliteOpenFactory openFactory, + {required Schema schema, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger}) { + final db = SqliteDatabase.withFactory(openFactory, maxReaders: maxReaders); + return PowerSyncDatabaseImpl.withDatabase( + schema: schema, database: db, logger: logger); + } + + /// Open a PowerSyncDatabase on an existing [SqliteDatabase]. + /// + /// Migrations are run on the database when this constructor is called. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds.s + PowerSyncDatabaseImpl.withDatabase( + {required this.schema, required this.database, Logger? logger}) { + if (logger != null) { + this.logger = logger; + } else { + this.logger = autoLogger; + } + isInitialized = baseInit(); + } + + @override + @internal + + /// Connect to the PowerSync service, and keep the databases in sync. + /// + /// The connection is automatically re-opened if it fails for any reason. + /// + /// Status changes are reported on [statusStream]. + baseConnect( + {required PowerSyncBackendConnector connector, + + /// Throttle time between CRUD operations + /// Defaults to 10 milliseconds. + required Duration crudThrottleTime, + required Future Function() reconnect, + Map? params}) async { + await initialize(); + + // Disconnect if connected + await disconnect(); + final disconnector = AbortController(); + disconnecter = disconnector; + + await isInitialized; + final dbRef = database.isolateConnectionFactory(); + ReceivePort rPort = ReceivePort(); + StreamSubscription? updateSubscription; + rPort.listen((data) async { + if (data is List) { + String action = data[0]; + if (action == "getCredentials") { + await (data[1] as PortCompleter).handle(() async { + final token = await connector.getCredentialsCached(); + logger.fine('Credentials: $token'); + return token; + }); + } else if (action == "invalidateCredentials") { + logger.fine('Refreshing credentials'); + await (data[1] as PortCompleter).handle(() async { + await connector.prefetchCredentials(); + }); + } else if (action == 'init') { + SendPort port = data[1]; + var throttled = + UpdateNotification.throttleStream(updates, crudThrottleTime); + updateSubscription = throttled.listen((event) { + port.send(['update']); + }); + disconnector.onAbort.then((_) { + port.send(['close']); + }).ignore(); + } else if (action == 'uploadCrud') { + await (data[1] as PortCompleter).handle(() async { + await connector.uploadData(this); + }); + } else if (action == 'status') { + final SyncStatus status = data[1]; + setStatus(status); + } else if (action == 'close') { + // Clear status apart from lastSyncedAt + setStatus(SyncStatus(lastSyncedAt: currentStatus.lastSyncedAt)); + rPort.close(); + updateSubscription?.cancel(); + } else if (action == 'log') { + LogRecord record = data[1]; + logger.log( + record.level, record.message, record.error, record.stackTrace); + } + } + }); + + var errorPort = ReceivePort(); + errorPort.listen((message) async { + // Sample error: + // flutter: [PowerSync] WARNING: 2023-06-28 16:34:11.566122: Sync Isolate error + // flutter: [Connection closed while receiving data, #0 IOClient.send. (package:http/src/io_client.dart:76:13) + // #1 Stream.handleError. (dart:async/stream.dart:929:16) + // #2 _HandleErrorStream._handleError (dart:async/stream_pipe.dart:269:17) + // #3 _ForwardingStreamSubscription._handleError (dart:async/stream_pipe.dart:157:13) + // #4 _HttpClientResponse.listen. (dart:_http/http_impl.dart:707:16) + // ... + logger.severe('Sync Isolate error', message); + + // Reconnect + // Use the param like this instead of directly calling connect(), to avoid recursive + // locks in some edge cases. + reconnect(); + }); + + disconnected() { + disconnector.completeAbort(); + disconnecter = null; + rPort.close(); + // Clear status apart from lastSyncedAt + setStatus(SyncStatus(lastSyncedAt: currentStatus.lastSyncedAt)); + } + + var exitPort = ReceivePort(); + exitPort.listen((message) { + logger.fine('Sync Isolate exit'); + disconnected(); + }); + + if (disconnecter?.aborted == true) { + disconnected(); + return; + } + + Isolate.spawn( + _powerSyncDatabaseIsolate, + _PowerSyncDatabaseIsolateArgs( + rPort.sendPort, dbRef, retryDelay, clientParams), + debugName: 'PowerSyncDatabase', + onError: errorPort.sendPort, + onExit: exitPort.sendPort); + } + + /// Takes a read lock, without starting a transaction. + /// + /// In most cases, [readTransaction] should be used instead. + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {String? debugContext, Duration? lockTimeout}) async { + await isInitialized; + return database.readLock(callback, + debugContext: debugContext, lockTimeout: lockTimeout); + } + + /// Takes a global lock, without starting a transaction. + /// + /// In most cases, [writeTransaction] should be used instead. + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {String? debugContext, Duration? lockTimeout}) async { + await isInitialized; + return database.writeLock(callback, + debugContext: debugContext, lockTimeout: lockTimeout); + } + + @override + Future updateSchema(Schema schema) { + if (disconnecter != null) { + throw AssertionError('Cannot update schema while connected'); + } + this.schema = schema; + return updateSchemaInIsolate(database, schema); + } +} + +class _PowerSyncDatabaseIsolateArgs { + final SendPort sPort; + final IsolateConnectionFactory dbRef; + final Duration retryDelay; + final Map? parameters; + + _PowerSyncDatabaseIsolateArgs( + this.sPort, this.dbRef, this.retryDelay, this.parameters); +} + +Future _powerSyncDatabaseIsolate( + _PowerSyncDatabaseIsolateArgs args) async { + final sPort = args.sPort; + ReceivePort rPort = ReceivePort(); + StreamController updateController = StreamController.broadcast(); + final upstreamDbClient = args.dbRef.upstreamPort.open(); + + CommonDatabase? db; + final Mutex mutex = args.dbRef.mutex.open(); + StreamingSyncImplementation? openedStreamingSync; + + rPort.listen((message) async { + if (message is List) { + String action = message[0]; + if (action == 'update') { + updateController.add('update'); + } else if (action == 'close') { + // The SyncSqliteConnection uses this mutex + // It needs to be closed before killing the isolate + // in order to free the mutex for other operations. + await mutex.close(); + db?.dispose(); + updateController.close(); + upstreamDbClient.close(); + // Abort any open http requests, and wait for it to be closed properly + await openedStreamingSync?.abort(); + // No kill the Isolate + Isolate.current.kill(); + } + } + }); + Isolate.current.addOnExitListener(sPort, response: const ['close']); + sPort.send(["init", rPort.sendPort]); + + // Is there a way to avoid the overhead if logging is not enabled? + // This only takes effect in this isolate. + isolateLogger.level = Level.ALL; + isolateLogger.onRecord.listen((record) { + var copy = LogRecord(record.level, record.message, record.loggerName, + record.error, record.stackTrace); + sPort.send(["log", copy]); + }); + + Future loadCredentials() async { + final r = IsolateResult(); + sPort.send(["getCredentials", r.completer]); + return r.future; + } + + Future invalidateCredentials() async { + final r = IsolateResult(); + sPort.send(["invalidateCredentials", r.completer]); + return r.future; + } + + Future uploadCrud() async { + final r = IsolateResult(); + sPort.send(["uploadCrud", r.completer]); + return r.future; + } + + runZonedGuarded(() async { + db = args.dbRef.openFactory + .open(SqliteOpenOptions(primaryConnection: false, readOnly: false)); + final connection = SyncSqliteConnection(db!, mutex); + + final storage = BucketStorage(connection); + final sync = StreamingSyncImplementation( + adapter: storage, + credentialsCallback: loadCredentials, + invalidCredentialsCallback: invalidateCredentials, + uploadCrud: uploadCrud, + updateStream: updateController.stream, + retryDelay: args.retryDelay, + client: http.Client(), + syncParameters: args.parameters); + openedStreamingSync = sync; + sync.streamingSync(); + sync.statusStream.listen((event) { + sPort.send(['status', event]); + }); + + Timer? updateDebouncer; + Set updatedTables = {}; + + void maybeFireUpdates() { + if (updatedTables.isNotEmpty) { + upstreamDbClient.fire(UpdateNotification(updatedTables)); + updatedTables.clear(); + updateDebouncer?.cancel(); + updateDebouncer = null; + } + } + + db!.updates.listen((event) { + updatedTables.add(event.tableName); + + updateDebouncer ??= + Timer(const Duration(milliseconds: 10), maybeFireUpdates); + }); + }, (error, stack) { + // Properly dispose the database if an uncaught error occurs. + // Unfortunately, this does not handle disposing while the database is opening. + // This should be rare - any uncaught error is a bug. And in most cases, + // it should occur after the database is already open. + db?.dispose(); + throw error; + }); +} diff --git a/packages/powersync/lib/src/database/powersync_database.dart b/packages/powersync/lib/src/database/powersync_database.dart new file mode 100644 index 00000000..abc68e34 --- /dev/null +++ b/packages/powersync/lib/src/database/powersync_database.dart @@ -0,0 +1,78 @@ +import 'package:logging/logging.dart'; +import 'package:powersync/src/database/powersync_database_impl.dart'; +import 'package:powersync/src/database/powersync_db_mixin.dart'; +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +import '../schema.dart'; + +/// A PowerSync managed database. +/// +/// Use one instance per database file. +/// +/// Use [PowerSyncDatabase.connect] to connect to the PowerSync service, +/// to keep the local database in sync with the remote database. +/// +/// All changes to local tables are automatically recorded, whether connected +/// or not. Once connected, the changes are uploaded. +abstract class PowerSyncDatabase + with SqliteQueries, PowerSyncDatabaseMixin + implements SqliteConnection { + /// Open a [PowerSyncDatabase]. + /// + /// Only a single [PowerSyncDatabase] per [path] should be opened at a time. + /// + /// The specified [schema] is used for the database. + /// + /// A connection pool is used by default, allowing multiple concurrent read + /// transactions, and a single concurrent write transaction. Write transactions + /// do not block read transactions, and read transactions will see the state + /// from the last committed write transaction. + /// + /// A maximum of [maxReaders] concurrent read transactions are allowed. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabase( + {required Schema schema, + required String path, + Logger? logger, + @Deprecated("Use [PowerSyncDatabase.withFactory] instead.") + // ignore: deprecated_member_use_from_same_package + SqliteConnectionSetup? sqliteSetup}) { + return PowerSyncDatabaseImpl( + schema: schema, + path: path, + logger: logger, + // ignore: deprecated_member_use_from_same_package + sqliteSetup: sqliteSetup); + } + + /// Open a [PowerSyncDatabase] with a [PowerSyncOpenFactory]. + /// + /// The factory determines which database file is opened, as well as any + /// additional logic to run inside the database isolate before or after opening. + /// + /// Subclass [PowerSyncOpenFactory] to add custom logic to this process. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabase.withFactory(DefaultSqliteOpenFactory openFactory, + {required Schema schema, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger}) { + return PowerSyncDatabaseImpl.withFactory(openFactory, + schema: schema, logger: logger); + } + + /// Open a PowerSyncDatabase on an existing [SqliteDatabase]. + /// + /// Migrations are run on the database when this constructor is called. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabase.withDatabase( + {required Schema schema, + required SqliteDatabase database, + Logger? loggers}) { + return PowerSyncDatabaseImpl.withDatabase( + schema: schema, database: database); + } +} diff --git a/packages/powersync/lib/src/database/powersync_database_impl.dart b/packages/powersync/lib/src/database/powersync_database_impl.dart new file mode 100644 index 00000000..7c98975f --- /dev/null +++ b/packages/powersync/lib/src/database/powersync_database_impl.dart @@ -0,0 +1,9 @@ +// This follows the pattern from here: https://stackoverflow.com/questions/58710226/how-to-import-platform-specific-dependency-in-flutter-dart-combine-web-with-an +// To conditionally export an implementation for either web or "native" platforms +// The sqlite library uses dart:ffi which is not supported on web + +export 'powersync_database_impl_stub.dart' + // ignore: uri_does_not_exist + if (dart.library.io) './native/native_powersync_database.dart' + // ignore: uri_does_not_exist + if (dart.library.html) './web/web_powersync_database.dart'; diff --git a/packages/powersync/lib/src/database/powersync_database_impl_stub.dart b/packages/powersync/lib/src/database/powersync_database_impl_stub.dart new file mode 100644 index 00000000..9dc96d4a --- /dev/null +++ b/packages/powersync/lib/src/database/powersync_database_impl_stub.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:powersync/sqlite_async.dart'; +import 'package:powersync/src/database/powersync_db_mixin.dart'; +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'powersync_database.dart'; + +import '../connector.dart'; +import '../schema.dart'; + +class PowerSyncDatabaseImpl + with SqliteQueries, PowerSyncDatabaseMixin + implements PowerSyncDatabase { + @override + Future close() { + throw UnimplementedError(); + } + + @override + bool get closed => throw UnimplementedError(); + + @override + Schema get schema => throw UnimplementedError(); + + @override + SqliteDatabase get database => throw UnimplementedError(); + + @override + Future get isInitialized => throw UnimplementedError(); + + /// Open a [PowerSyncDatabase]. + /// + /// Only a single [PowerSyncDatabase] per [path] should be opened at a time. + /// + /// The specified [schema] is used for the database. + /// + /// A connection pool is used by default, allowing multiple concurrent read + /// transactions, and a single concurrent write transaction. Write transactions + /// do not block read transactions, and read transactions will see the state + /// from the last committed write transaction. + /// + /// A maximum of [maxReaders] concurrent read transactions are allowed. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabaseImpl( + {required Schema schema, + required String path, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger, + @Deprecated("Use [PowerSyncDatabase.withFactory] instead.") + // ignore: deprecated_member_use_from_same_package + SqliteConnectionSetup? sqliteSetup}) { + throw UnimplementedError(); + } + + /// Open a [PowerSyncDatabase] with a [PowerSyncOpenFactory]. + /// + /// The factory determines which database file is opened, as well as any + /// additional logic to run inside the database isolate before or after opening. + /// + /// Subclass [PowerSyncOpenFactory] to add custom logic to this process. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds.s + factory PowerSyncDatabaseImpl.withFactory( + DefaultSqliteOpenFactory openFactory, { + required Schema schema, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger, + }) { + throw UnimplementedError(); + } + + /// Open a PowerSyncDatabase on an existing [SqliteDatabase]. + /// + /// Migrations are run on the database when this constructor is called. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds.s + factory PowerSyncDatabaseImpl.withDatabase( + {required Schema schema, + required SqliteDatabase database, + Logger? loggers}) { + throw UnimplementedError(); + } + + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {String? debugContext, Duration? lockTimeout}) { + throw UnimplementedError(); + } + + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {String? debugContext, Duration? lockTimeout}) { + throw UnimplementedError(); + } + + @override + Future getAutoCommit() { + throw UnimplementedError(); + } + + @override + Future updateSchema(Schema schema) { + throw UnimplementedError(); + } + + @override + Logger get logger => throw UnimplementedError(); + + @override + @internal + Future baseConnect( + {required PowerSyncBackendConnector connector, + required Duration crudThrottleTime, + required Future Function() reconnect, + Map? params}) { + throw UnimplementedError(); + } +} diff --git a/packages/powersync/lib/src/database/powersync_db_mixin.dart b/packages/powersync/lib/src/database/powersync_db_mixin.dart new file mode 100644 index 00000000..b205e210 --- /dev/null +++ b/packages/powersync/lib/src/database/powersync_db_mixin.dart @@ -0,0 +1,415 @@ +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:powersync/sqlite3_common.dart'; +import 'package:powersync/sqlite_async.dart'; +import 'package:powersync/src/abort_controller.dart'; +import 'package:powersync/src/connector.dart'; +import 'package:powersync/src/crud.dart'; +import 'package:powersync/src/powersync_update_notification.dart'; +import 'package:powersync/src/schema.dart'; +import 'package:powersync/src/schema_logic.dart'; +import 'package:powersync/src/sync_status.dart'; + +mixin PowerSyncDatabaseMixin implements SqliteConnection { + /// Schema used for the local database. + Schema get schema; + + /// The underlying database. + /// + /// For the most part, behavior is the same whether querying on the underlying + /// database, or on [PowerSyncDatabase]. The main difference is in update notifications: + /// the underlying database reports updates to the underlying tables, while + /// [PowerSyncDatabase] reports updates to the higher-level views. + SqliteDatabase get database; + + /// The Logger used by this [PowerSyncDatabase]. + /// + /// The default is [autoLogger], which logs to the console in debug builds. + /// Use [debugLogger] to always log to the console. + /// Use [attachedLogger] to propagate logs to [Logger.root] for custom logging. + Logger get logger; + + Map? clientParams; + + /// Current connection status. + SyncStatus currentStatus = + const SyncStatus(connected: false, lastSyncedAt: null); + + /// Use this stream to subscribe to connection status updates. + late final Stream statusStream; + + @protected + StreamController statusStreamController = + StreamController.broadcast(); + + /// Use to prevent multiple connections from being opened concurrently + final Mutex _connectMutex = Mutex(); + + @override + + /// Broadcast stream that is notified of any table updates. + /// + /// Unlike in [SqliteDatabase.updates], the tables reported here are the + /// higher-level views as defined in the [Schema], and exclude the low-level + /// PowerSync tables. + late final Stream updates; + + /// Delay between retrying failed requests. + /// Defaults to 5 seconds. + /// Only has an effect if changed before calling [connect]. + Duration retryDelay = const Duration(seconds: 5); + + @protected + Future get isInitialized; + + /// null when disconnected, present when connecting or connected + @protected + AbortController? disconnecter; + + @protected + Future baseInit() async { + statusStream = statusStreamController.stream; + updates = database.updates + .map((update) => + PowerSyncUpdateNotification.fromUpdateNotification(update)) + .where((update) => update.isNotEmpty) + .cast(); + + await database.initialize(); + await database.execute('SELECT powersync_init()'); + await updateSchema(schema); + await _updateHasSynced(); + } + + /// Wait for initialization to complete. + /// + /// While initializing is automatic, this helps to catch and report initialization errors. + Future initialize() { + return isInitialized; + } + + Future _updateHasSynced() async { + const syncedSQL = + 'SELECT 1 FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1'; + + // Query the database to see if any data has been synced. + final result = await database.execute(syncedSQL); + final hasSynced = result.rows.isNotEmpty; + + if (hasSynced != currentStatus.hasSynced) { + final status = SyncStatus(hasSynced: hasSynced); + setStatus(status); + } + } + + @protected + void setStatus(SyncStatus status) { + if (status != currentStatus) { + currentStatus = status.copyWith( + // 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. + hasSynced: status.lastSyncedAt != null + ? true + : status.hasSynced ?? currentStatus.hasSynced); + statusStreamController.add(currentStatus); + } + } + + @override + bool get closed { + return database.closed; + } + + /// Close the database, releasing resources. + /// + /// Also [disconnect]s any active connection. + /// + /// Once close is called, this connection cannot be used again - a new one + /// must be constructed. + @override + Future close() async { + // Don't close in the middle of the initialization process. + await isInitialized; + // Disconnect any active sync connection. + await disconnect(); + // Now we can close the database + await database.close(); + } + + /// Connect to the PowerSync service, and keep the databases in sync. + /// + /// The connection is automatically re-opened if it fails for any reason. + /// + /// Status changes are reported on [statusStream]. + Future connect( + {required PowerSyncBackendConnector connector, + + /// Throttle time between CRUD operations + /// Defaults to 10 milliseconds. + Duration crudThrottleTime = const Duration(milliseconds: 10), + Map? params}) async { + clientParams = params; + Zone current = Zone.current; + + Future reconnect() { + return _connectMutex.lock(() => baseConnect( + connector: connector, + crudThrottleTime: crudThrottleTime, + // The reconnect function needs to run in the original zone, + // to avoid recursive lock errors. + reconnect: current.bindCallback(reconnect), + params: params)); + } + + await reconnect(); + } + + /// Abstract connection method to be implemented by platform specific + /// classes. This is wrapped inside an exclusive mutex in the [connect] + /// method. + @protected + @internal + Future baseConnect( + {required PowerSyncBackendConnector connector, + + /// Throttle time between CRUD operations + /// Defaults to 10 milliseconds. + required Duration crudThrottleTime, + required Future Function() reconnect, + Map? params}); + + /// Close the sync connection. + /// + /// Use [connect] to connect again. + Future disconnect() async { + if (disconnecter != null) { + /// 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 (disconnecter!.aborted == false) { + await disconnecter!.abort(); + disconnecter = null; + } else { + /// Wait for the abort to complete. Continue updating the sync status after completed + await disconnecter!.onAbort; + } + } + setStatus( + SyncStatus(connected: false, lastSyncedAt: currentStatus.lastSyncedAt)); + } + + /// Disconnect and clear the database. + /// + /// Use this when logging out. + /// + /// The database can still be queried after this is called, but the tables + /// would be empty. + /// + /// To preserve data in local-only tables, set [clearLocal] to false. + Future disconnectAndClear({bool clearLocal = true}) async { + await disconnect(); + + await writeTransaction((tx) async { + await tx.execute('DELETE FROM ps_oplog'); + await tx.execute('DELETE FROM ps_crud'); + await tx.execute('DELETE FROM ps_buckets'); + await tx.execute('DELETE FROM ps_untyped'); + + final tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*'; + final existingTableRows = await tx.getAll( + "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ?", + [tableGlob]); + + for (var row in existingTableRows) { + await tx.execute('DELETE FROM ${quoteIdentifier(row['name'])}'); + } + }); + } + + @Deprecated('Use [disconnectAndClear] instead.') + Future disconnectedAndClear() async { + await disconnectAndClear(); + } + + /// Whether a connection to the PowerSync service is currently open. + bool get connected { + return currentStatus.connected; + } + + /// Replace the schema with a new version. + /// This is for advanced use cases - typically the schema should just be + /// specified once in the constructor. + /// + /// Cannot be used while connected - this should only be called before [connect]. + Future updateSchema(Schema schema); + + /// A connection factory that can be passed to different isolates. + /// + /// Use this to access the database in background isolates. + isolateConnectionFactory() { + return database.isolateConnectionFactory(); + } + + /// Get upload queue size estimate and count. + Future getUploadQueueStats( + {bool includeSize = false}) async { + if (includeSize) { + final row = await getOptional( + 'SELECT SUM(cast(data as blob) + 20) as size, count(*) as count FROM ps_crud'); + return UploadQueueStats( + count: row?['count'] ?? 0, size: row?['size'] ?? 0); + } else { + final row = await getOptional('SELECT count(*) as count FROM ps_crud'); + return UploadQueueStats(count: row?['count'] ?? 0); + } + } + + /// Get a batch of crud data to upload. + /// + /// Returns null if there is no data to upload. + /// + /// Use this from the [PowerSyncBackendConnector.uploadData]` callback. + /// + /// Once the data have been successfully uploaded, call [CrudBatch.complete] before + /// requesting the next batch. + /// + /// Use [limit] to specify the maximum number of updates to return in a single + /// batch. + /// + /// This method does include transaction ids in the result, but does not group + /// data by transaction. One batch may contain data from multiple transactions, + /// and a single transaction may be split over multiple batches. + Future getCrudBatch({limit = 100}) async { + final rows = await getAll( + 'SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT ?', + [limit + 1]); + List all = [for (var row in rows) CrudEntry.fromRow(row)]; + + var haveMore = false; + if (all.length > limit) { + all.removeLast(); + haveMore = true; + } + if (all.isEmpty) { + return null; + } + 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 = $writeCheckpoint WHERE name=\'\$local\''); + } else { + await db.execute( + 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); + } + }); + }); + } + + /// Get the next recorded transaction to upload. + /// + /// Returns null if there is no data to upload. + /// + /// Use this from the [PowerSyncBackendConnector.uploadData]` callback. + /// + /// Once the data have been successfully uploaded, call [CrudTransaction.complete] before + /// requesting the next transaction. + /// + /// 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 int? txId = first['tx_id']; + 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)]; + } + + 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 = $writeCheckpoint WHERE name=\'\$local\''); + } else { + await db.execute( + 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); + } + }); + }); + }); + } + + /// Takes a read lock, without starting a transaction. + /// + /// In most cases, [readTransaction] should be used instead. + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {String? debugContext, Duration? lockTimeout}); + + /// Takes a global lock, without starting a transaction. + /// + /// In most cases, [writeTransaction] should be used instead. + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {String? debugContext, Duration? lockTimeout}); + + @override + Stream watch(String sql, + {List parameters = const [], + Duration throttle = const Duration(milliseconds: 30), + Iterable? triggerOnTables}) { + if (triggerOnTables == null || triggerOnTables.isEmpty) { + return database.watch(sql, parameters: parameters, throttle: throttle); + } + List powersyncTables = []; + for (String tableName in triggerOnTables) { + powersyncTables.add(tableName); + powersyncTables.add(_prefixTableNames(tableName, 'ps_data__')); + powersyncTables.add(_prefixTableNames(tableName, 'ps_data_local__')); + } + return database.watch(sql, + parameters: parameters, + throttle: throttle, + triggerOnTables: powersyncTables); + } + + @protected + String _prefixTableNames(String tableName, String prefix) { + String prefixedString = tableName.replaceRange(0, 0, prefix); + return prefixedString; + } + + @override + Future getAutoCommit() { + return database.getAutoCommit(); + } +} diff --git a/packages/powersync/lib/src/database/web/web_powersync_database.dart b/packages/powersync/lib/src/database/web/web_powersync_database.dart new file mode 100644 index 00000000..5142fa80 --- /dev/null +++ b/packages/powersync/lib/src/database/web/web_powersync_database.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'package:meta/meta.dart'; +import 'package:fetch_client/fetch_client.dart'; +import 'package:logging/logging.dart'; +import 'package:powersync/src/abort_controller.dart'; +import 'package:powersync/src/bucket_storage.dart'; +import 'package:powersync/src/connector.dart'; +import 'package:powersync/src/database/powersync_database.dart'; +import 'package:powersync/src/database/powersync_db_mixin.dart'; +import 'package:powersync/src/log.dart'; +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'package:powersync/src/open_factory/web/web_open_factory.dart'; +import 'package:powersync/src/schema.dart'; +import 'package:powersync/src/streaming_sync.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:powersync/src/schema_logic.dart' as schema_logic; + +/// A PowerSync managed database. +/// +/// Web implementation for [PowerSyncDatabase] +/// +/// Use one instance per database file. +/// +/// Use [PowerSyncDatabase.connect] to connect to the PowerSync service, +/// to keep the local database in sync with the remote database. +/// +/// All changes to local tables are automatically recorded, whether connected +/// or not. Once connected, the changes are uploaded. +class PowerSyncDatabaseImpl + with SqliteQueries, PowerSyncDatabaseMixin + implements PowerSyncDatabase { + @override + Schema schema; + + @override + SqliteDatabase database; + + late final DefaultSqliteOpenFactory openFactory; + + @override + @protected + late Future isInitialized; + + @override + + /// The Logger used by this [PowerSyncDatabase]. + /// + /// The default is [autoLogger], which logs to the console in debug builds. + /// Use [debugLogger] to always log to the console. + /// Use [attachedLogger] to propagate logs to [Logger.root] for custom logging. + late final Logger logger; + + /// Open a [PowerSyncDatabase]. + /// + /// Only a single [PowerSyncDatabase] per [path] should be opened at a time. + /// + /// The specified [schema] is used for the database. + /// + /// A connection pool is used by default, allowing multiple concurrent read + /// transactions, and a single concurrent write transaction. Write transactions + /// do not block read transactions, and read transactions will see the state + /// from the last committed write transaction. + /// + /// A maximum of [maxReaders] concurrent read transactions are allowed. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabaseImpl( + {required Schema schema, + required String path, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger, + @Deprecated("Use [PowerSyncDatabase.withFactory] instead.") + // ignore: deprecated_member_use_from_same_package + SqliteConnectionSetup? sqliteSetup}) { + // ignore: deprecated_member_use_from_same_package + DefaultSqliteOpenFactory factory = PowerSyncOpenFactory(path: path); + return PowerSyncDatabaseImpl.withFactory(factory, + maxReaders: maxReaders, logger: logger, schema: schema); + } + + /// Open a [PowerSyncDatabase] with a [PowerSyncOpenFactory]. + /// + /// The factory determines which database file is opened, as well as any + /// additional logic to run inside the database isolate before or after opening. + /// + /// Subclass [PowerSyncOpenFactory] to add custom logic to this process. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + factory PowerSyncDatabaseImpl.withFactory( + DefaultSqliteOpenFactory openFactory, + {required Schema schema, + int maxReaders = SqliteDatabase.defaultMaxReaders, + Logger? logger}) { + final db = SqliteDatabase.withFactory(openFactory, maxReaders: 1); + return PowerSyncDatabaseImpl.withDatabase( + schema: schema, logger: logger, database: db); + } + + /// Open a PowerSyncDatabase on an existing [SqliteDatabase]. + /// + /// Migrations are run on the database when this constructor is called. + /// + /// [logger] defaults to [autoLogger], which logs to the console in debug builds. + PowerSyncDatabaseImpl.withDatabase( + {required this.schema, required this.database, Logger? logger}) { + if (logger != null) { + this.logger = logger; + } else { + this.logger = autoLogger; + } + isInitialized = baseInit(); + } + + @override + @internal + + /// Connect to the PowerSync service, and keep the databases in sync. + /// + /// The connection is automatically re-opened if it fails for any reason. + /// + /// Status changes are reported on [statusStream]. + baseConnect( + {required PowerSyncBackendConnector connector, + + /// Throttle time between CRUD operations + /// Defaults to 10 milliseconds. + required Duration crudThrottleTime, + required Future Function() reconnect, + Map? params}) async { + await initialize(); + + // Disconnect if connected + await disconnect(); + disconnecter = AbortController(); + + await isInitialized; + + // TODO better multitab support + final storage = BucketStorage(database); + final sync = StreamingSyncImplementation( + adapter: storage, + credentialsCallback: connector.getCredentialsCached, + invalidCredentialsCallback: connector.fetchCredentials, + uploadCrud: () => connector.uploadData(this), + updateStream: updates, + retryDelay: Duration(seconds: 3), + client: FetchClient(mode: RequestMode.cors), + syncParameters: params, + // 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); + sync.statusStream.listen((event) { + setStatus(event); + }); + sync.streamingSync(); + disconnecter?.onAbort.then((_) async { + await sync.abort(); + disconnecter?.completeAbort(); + }).ignore(); + } + + /// Takes a read lock, without starting a transaction. + /// + /// In most cases, [readTransaction] should be used instead. + @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 readTransaction( + Future Function(SqliteReadContext tx) callback, + {Duration? lockTimeout, + String? debugContext}) async { + await isInitialized; + return database.readTransaction(callback, lockTimeout: lockTimeout); + } + + /// Takes a global lock, without starting a transaction. + /// + /// In most cases, [writeTransaction] should be used instead. + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {String? debugContext, Duration? lockTimeout}) async { + await isInitialized; + return database.writeLock(callback, + debugContext: debugContext, lockTimeout: lockTimeout); + } + + /// Uses the database writeTransaction instead of the locally + /// scoped writeLock. This is to allow the Database transaction + /// tracking to be correctly configured. + @override + Future writeTransaction( + Future Function(SqliteWriteContext tx) callback, + {Duration? lockTimeout, + String? debugContext}) async { + await isInitialized; + return database.writeTransaction(callback, lockTimeout: lockTimeout); + } + + @override + Future updateSchema(Schema schema) { + if (disconnecter != null) { + throw AssertionError('Cannot update schema while connected'); + } + this.schema = schema; + return database.writeLock((tx) => schema_logic.updateSchema(tx, schema)); + } +} diff --git a/packages/powersync/lib/src/database_utils.dart b/packages/powersync/lib/src/database_utils.dart deleted file mode 100644 index 5a67e091..00000000 --- a/packages/powersync/lib/src/database_utils.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; - -import 'package:sqlite_async/sqlite3.dart' as sqlite; -import 'package:sqlite_async/sqlite3_common.dart'; - -Future asyncDirectTransaction( - CommonDatabase db, FutureOr Function(CommonDatabase db) callback) async { - for (var i = 50; i >= 0; i--) { - try { - db.execute('BEGIN IMMEDIATE'); - late T result; - try { - result = await callback(db); - db.execute('COMMIT'); - } catch (e) { - try { - db.execute('ROLLBACK'); - } catch (e2) { - // Safe to ignore - } - rethrow; - } - - return result; - } catch (e) { - if (e is sqlite.SqliteException) { - if (e.resultCode == 5 && i != 0) { - // SQLITE_BUSY - await Future.delayed(const Duration(milliseconds: 50)); - continue; - } - } - rethrow; - } - } - throw AssertionError('Should not reach this'); -} diff --git a/packages/powersync/lib/src/log_internal.dart b/packages/powersync/lib/src/log_internal.dart index fe9d46d2..22608a25 100644 --- a/packages/powersync/lib/src/log_internal.dart +++ b/packages/powersync/lib/src/log_internal.dart @@ -3,6 +3,7 @@ import 'package:logging/logging.dart'; // Duplicate from package:flutter/foundation.dart, so we don't need to depend on Flutter const bool kReleaseMode = bool.fromEnvironment('dart.vm.product'); const bool kProfileMode = bool.fromEnvironment('dart.vm.profile'); +const bool kIsWeb = bool.fromEnvironment('dart.library.js_util'); const bool kDebugMode = !kReleaseMode && !kProfileMode; // Implementation note: The loggers here are only initialized if used - it adds diff --git a/packages/powersync/lib/src/open_factory.dart b/packages/powersync/lib/src/open_factory.dart index d2bd033d..c32daf83 100644 --- a/packages/powersync/lib/src/open_factory.dart +++ b/packages/powersync/lib/src/open_factory.dart @@ -1,148 +1,9 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:ffi'; -import 'dart:math'; - -import 'package:powersync/sqlite3.dart'; -import 'package:powersync/src/exceptions.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; -import 'package:sqlite_async/sqlite3_common.dart'; -import 'package:sqlite_async/sqlite_async.dart'; - -/// Advanced: Define custom setup for each SQLite connection. -@Deprecated('Use SqliteOpenFactory instead') -class SqliteConnectionSetup { - final FutureOr Function() _setup; - - /// The setup parameter is called every time a database connection is opened. - /// This can be used to configure dynamic library loading if required. - const SqliteConnectionSetup(FutureOr Function() setup) : _setup = setup; - - Future setup() async { - await _setup(); - } -} - -class PowerSyncOpenFactory extends DefaultSqliteOpenFactory { - @Deprecated('Override PowerSyncOpenFactory instead.') - final SqliteConnectionSetup? _sqliteSetup; - - PowerSyncOpenFactory( - {required super.path, - super.sqliteOptions, - @Deprecated('Override PowerSyncOpenFactory instead.') - // ignore: deprecated_member_use_from_same_package - SqliteConnectionSetup? sqliteSetup}) - // ignore: deprecated_member_use_from_same_package - : _sqliteSetup = sqliteSetup; - - @override - CommonDatabase open(SqliteOpenOptions options) { - // ignore: deprecated_member_use_from_same_package - _sqliteSetup?.setup(); - - enableExtension(); - - final db = _retriedOpen(options); - db.execute('PRAGMA recursive_triggers = TRUE'); - setupFunctions(db); - return db; - } - - /// When opening the powersync connection and the standard write connection - /// at the same time, one could fail with this error: - /// - /// SqliteException(5): while opening the database, automatic extension loading failed: , database is locked (code 5) - /// - /// It happens before we have a chance to set the busy timeout, so we just - /// retry opening the database. - /// - /// Usually a delay of 1-2ms is sufficient for the next try to succeed, but - /// we increase the retry delay up to 16ms per retry, and a maximum of 500ms - /// in total. - CommonDatabase _retriedOpen(SqliteOpenOptions options) { - final stopwatch = Stopwatch()..start(); - var retryDelay = 2; - while (stopwatch.elapsedMilliseconds < 500) { - try { - return super.open(options); - } catch (e) { - if (e is sqlite.SqliteException && e.resultCode == 5) { - sleep(Duration(milliseconds: retryDelay)); - retryDelay = min(retryDelay * 2, 16); - continue; - } - rethrow; - } - } - throw AssertionError('Cannot reach this point'); - } - - void enableExtension() { - var powersyncLib = _getDynamicLibraryForPlatform(); - sqlite.sqlite3.ensureExtensionLoaded( - SqliteExtension.inLibrary(powersyncLib, 'sqlite3_powersync_init')); - } - - /// Returns the dynamic library for the current platform. - DynamicLibrary _getDynamicLibraryForPlatform() { - /// When running tests, we need to load the library for all platforms. - if (Platform.environment.containsKey('FLUTTER_TEST')) { - return DynamicLibrary.open(getLibraryForPlatform()); - } - return (Platform.isIOS || Platform.isMacOS) - ? DynamicLibrary.process() - : DynamicLibrary.open(getLibraryForPlatform()); - } - - void setupFunctions(CommonDatabase db) { - db.createFunction( - functionName: 'powersync_sleep', - argumentCount: const sqlite.AllowedArgumentCount(1), - function: (args) { - final millis = args[0] as int; - sleep(Duration(milliseconds: millis)); - return millis; - }, - ); - - db.createFunction( - functionName: 'powersync_connection_name', - argumentCount: const sqlite.AllowedArgumentCount(0), - function: (args) { - return Isolate.current.debugName; - }, - ); - } - - /// 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}) { - switch (Abi.current()) { - case Abi.androidArm: - case Abi.androidArm64: - case Abi.androidX64: - return 'libpowersync.so'; - case Abi.macosArm64: - case Abi.macosX64: - return 'libpowersync.dylib'; - case Abi.linuxX64: - return 'libpowersync.so'; - case Abi.windowsArm64: - case Abi.windowsX64: - return 'powersync.dll'; - case Abi.androidIA32: - throw PowersyncNotReadyException( - 'Unsupported processor architecture. X86 Android emulators are not ' - 'supported. Please use an x86_64 emulator instead. All physical ' - 'Android devices are supported including 32bit ARM.', - ); - default: - throw PowersyncNotReadyException( - 'Unsupported processor architecture "${Abi.current()}". ' - 'Please open an issue on GitHub to request it.', - ); - } - } -} +// This follows the pattern from here: https://stackoverflow.com/questions/58710226/how-to-import-platform-specific-dependency-in-flutter-dart-combine-web-with-an +// To conditionally export an implementation for either web or "native" platforms +// The sqlite library uses dart:ffi which is not supported on web + +export './open_factory/open_factory_stub.dart' + // ignore: uri_does_not_exist + if (dart.library.io) './open_factory/native/native_open_factory.dart' + // ignore: uri_does_not_exist + if (dart.library.html) './open_factory/web/web_open_factory.dart'; diff --git a/packages/powersync/lib/src/open_factory/abstract_powersync_open_factory.dart b/packages/powersync/lib/src/open_factory/abstract_powersync_open_factory.dart new file mode 100644 index 00000000..06e8feda --- /dev/null +++ b/packages/powersync/lib/src/open_factory/abstract_powersync_open_factory.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:universal_io/io.dart'; +import 'dart:math'; + +import 'package:powersync/sqlite_async.dart'; +import 'package:powersync/src/open_factory/common_db_functions.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; + +const powerSyncDefaultSqliteOptions = SqliteOptions( + webSqliteOptions: WebSqliteOptions( + wasmUri: 'sqlite3.wasm', workerUri: 'powersync_db.worker.js')); + +abstract class AbstractPowerSyncOpenFactory extends DefaultSqliteOpenFactory { + AbstractPowerSyncOpenFactory( + {required super.path, + super.sqliteOptions = powerSyncDefaultSqliteOptions}); + + void enableExtension(); + + void setupFunctions(CommonDatabase db) { + return setupCommonDBFunctions(db); + } + + @override + List pragmaStatements(SqliteOpenOptions options) { + final basePragmaStatements = super.pragmaStatements(options); + basePragmaStatements.add('PRAGMA recursive_triggers = TRUE'); + return basePragmaStatements; + } + + @override + CommonDatabase open(SqliteOpenOptions options) { + var db = _retriedOpen(options); + for (final statement in pragmaStatements(options)) { + db.select(statement); + } + setupFunctions(db); + return db; + } + + /// When opening the powersync connection and the standard write connection + /// at the same time, one could fail with this error: + /// + /// SqliteException(5): while opening the database, automatic extension loading failed: , database is locked (code 5) + /// + /// It happens before we have a chance to set the busy timeout, so we just + /// retry opening the database. + /// + /// Usually a delay of 1-2ms is sufficient for the next try to succeed, but + /// we increase the retry delay up to 16ms per retry, and a maximum of 500ms + /// in total. + CommonDatabase _retriedOpen(SqliteOpenOptions options) { + final stopwatch = Stopwatch()..start(); + var retryDelay = 2; + while (stopwatch.elapsedMilliseconds < 500) { + try { + return super.open(options); + } catch (e) { + if (e is SqliteException && e.resultCode == 5) { + sleep(Duration(milliseconds: retryDelay)); + retryDelay = min(retryDelay * 2, 16); + continue; + } + rethrow; + } + } + throw AssertionError('Cannot reach this point'); + } + + /// 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}); +} + +/// Advanced: Define custom setup for each SQLite connection. +@Deprecated('Use SqliteOpenFactory instead.') +class SqliteConnectionSetup { + final FutureOr Function() _setup; + + /// The setup parameter is called every time a database connection is opened. + /// This can be used to configure dynamic library loading if required. + const SqliteConnectionSetup(FutureOr Function() setup) : _setup = setup; + + Future setup() async { + await _setup(); + } +} diff --git a/packages/powersync/lib/src/open_factory/common_db_functions.dart b/packages/powersync/lib/src/open_factory/common_db_functions.dart new file mode 100644 index 00000000..f36906f1 --- /dev/null +++ b/packages/powersync/lib/src/open_factory/common_db_functions.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:sqlite_async/sqlite3_common.dart' as sqlite; +import 'package:sqlite_async/sqlite3_common.dart'; + +void setupCommonDBFunctions(CommonDatabase db) { + db.createFunction( + functionName: 'powersync_diff', + argumentCount: const sqlite.AllowedArgumentCount(2), + deterministic: true, + directOnly: false, + function: (args) { + final oldData = jsonDecode(args[0] as String) as Map; + final newData = jsonDecode(args[1] as String) as Map; + + Map result = {}; + + for (final newEntry in newData.entries) { + final oldValue = oldData[newEntry.key]; + final newValue = newEntry.value; + + if (newValue != oldValue) { + result[newEntry.key] = newValue; + } + } + + for (final key in oldData.keys) { + if (!newData.containsKey(key)) { + result[key] = null; + } + } + + return jsonEncode(result); + }); +} diff --git a/packages/powersync/lib/src/open_factory/native/native_open_factory.dart b/packages/powersync/lib/src/open_factory/native/native_open_factory.dart new file mode 100644 index 00000000..9f0d3713 --- /dev/null +++ b/packages/powersync/lib/src/open_factory/native/native_open_factory.dart @@ -0,0 +1,104 @@ +import 'dart:ffi'; + +import 'package:powersync/powersync.dart'; +import 'package:universal_io/io.dart'; +import 'dart:isolate'; +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'package:sqlite_async/sqlite3.dart' as sqlite; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// Native implementation for [AbstractPowerSyncOpenFactory] +class PowerSyncOpenFactory extends AbstractPowerSyncOpenFactory { + @Deprecated('Override PowerSyncOpenFactory instead.') + final SqliteConnectionSetup? _sqliteSetup; + + PowerSyncOpenFactory( + {required super.path, + super.sqliteOptions, + @Deprecated('Override PowerSyncOpenFactory instead.') + SqliteConnectionSetup? sqliteSetup}) + // ignore: deprecated_member_use_from_same_package + : _sqliteSetup = sqliteSetup; + + @override + void enableExtension() { + var powersyncLib = _getDynamicLibraryForPlatform(); + sqlite.sqlite3.ensureExtensionLoaded(sqlite.SqliteExtension.inLibrary( + powersyncLib, 'sqlite3_powersync_init')); + } + + /// Returns the dynamic library for the current platform. + DynamicLibrary _getDynamicLibraryForPlatform() { + /// When running tests, we need to load the library for all platforms. + if (Platform.environment.containsKey('FLUTTER_TEST')) { + return DynamicLibrary.open(getLibraryForPlatform()); + } + return (Platform.isIOS || Platform.isMacOS) + ? DynamicLibrary.process() + : DynamicLibrary.open(getLibraryForPlatform()); + } + + @override + setupFunctions(CommonDatabase db) { + super.setupFunctions(db); + db.createFunction( + functionName: 'powersync_sleep', + argumentCount: const sqlite.AllowedArgumentCount(1), + function: (args) { + final millis = args[0] as int; + sleep(Duration(milliseconds: millis)); + return millis; + }, + ); + + db.createFunction( + functionName: 'powersync_connection_name', + argumentCount: const sqlite.AllowedArgumentCount(0), + function: (args) { + return Isolate.current.debugName; + }, + ); + } + + @override + CommonDatabase open(SqliteOpenOptions options) { + // ignore: deprecated_member_use_from_same_package + _sqliteSetup?.setup(); + + enableExtension(); + + var db = super.open(options); + db.execute('PRAGMA recursive_triggers = TRUE'); + return db; + } + + @override + String getLibraryForPlatform({String? path}) { + switch (Abi.current()) { + case Abi.androidArm: + case Abi.androidArm64: + case Abi.androidX64: + return 'libpowersync.so'; + case Abi.macosArm64: + case Abi.macosX64: + return 'libpowersync.dylib'; + case Abi.linuxX64: + return 'libpowersync.so'; + case Abi.windowsArm64: + case Abi.windowsX64: + return 'powersync.dll'; + case Abi.androidIA32: + throw PowersyncNotReadyException( + 'Unsupported processor architecture. X86 Android emulators are not ' + 'supported. Please use an x86_64 emulator instead. All physical ' + 'Android devices are supported including 32bit ARM.', + ); + default: + throw PowersyncNotReadyException( + 'Unsupported processor architecture "${Abi.current()}". ' + 'Please open an issue on GitHub to request it.', + ); + } + } +} diff --git a/packages/powersync/lib/src/open_factory/open_factory_stub.dart b/packages/powersync/lib/src/open_factory/open_factory_stub.dart new file mode 100644 index 00000000..182cf895 --- /dev/null +++ b/packages/powersync/lib/src/open_factory/open_factory_stub.dart @@ -0,0 +1,26 @@ +import 'package:sqlite_async/sqlite3_common.dart'; +import 'abstract_powersync_open_factory.dart' as open_factory; + +class PowerSyncOpenFactory extends open_factory.AbstractPowerSyncOpenFactory { + PowerSyncOpenFactory( + {required super.path, + super.sqliteOptions, + @Deprecated('Override PowerSyncOpenFactory instead.') + // ignore: deprecated_member_use_from_same_package + open_factory.SqliteConnectionSetup? sqliteSetup}); + + @override + void enableExtension() { + throw UnimplementedError(); + } + + @override + void setupFunctions(CommonDatabase db) { + throw UnimplementedError(); + } + + @override + String getLibraryForPlatform({String? path}) { + throw UnimplementedError(); + } +} diff --git a/packages/powersync/lib/src/open_factory/web/web_open_factory.dart b/packages/powersync/lib/src/open_factory/web/web_open_factory.dart new file mode 100644 index 00000000..8c137393 --- /dev/null +++ b/packages/powersync/lib/src/open_factory/web/web_open_factory.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:powersync/src/open_factory/abstract_powersync_open_factory.dart'; +import 'package:powersync/src/uuid.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// Web implementation for [AbstractPowerSyncOpenFactory] +class PowerSyncOpenFactory extends AbstractPowerSyncOpenFactory { + PowerSyncOpenFactory({ + required super.path, + super.sqliteOptions, + }); + + @override + void enableExtension() { + // No op for web + } + + @override + Future openConnection(SqliteOpenOptions options) async { + var conn = await super.openConnection(options); + for (final statement in super.pragmaStatements(options)) { + await conn.execute(statement); + } + + return super.openConnection(options); + } + + @override + + /// This is only called when synchronous connections are created in the same + /// Dart/JS context. Worker runners need to setupFunctions manually + setupFunctions(CommonDatabase db) { + super.setupFunctions(db); + + db.createFunction( + functionName: 'uuid', + argumentCount: const AllowedArgumentCount(0), + function: (args) { + return uuid.v4(); + }, + ); + db.createFunction( + // Postgres compatibility + functionName: 'gen_random_uuid', + argumentCount: const AllowedArgumentCount(0), + function: (args) => uuid.v4(), + ); + } + + @override + String getLibraryForPlatform({String? path}) { + // no op for web + return ""; + } +} diff --git a/packages/powersync/lib/src/powersync_database.dart b/packages/powersync/lib/src/powersync_database.dart index 7ccdc174..c051b90d 100644 --- a/packages/powersync/lib/src/powersync_database.dart +++ b/packages/powersync/lib/src/powersync_database.dart @@ -1,718 +1 @@ -import 'dart:async'; -import 'dart:isolate'; - -import 'package:logging/logging.dart'; -import 'package:powersync/src/log_internal.dart'; -import 'package:sqlite_async/sqlite3_common.dart'; -import 'package:sqlite_async/sqlite_async.dart'; - -import 'abort_controller.dart'; -import 'bucket_storage.dart'; -import 'connector.dart'; -import 'crud.dart'; -import 'isolate_completer.dart'; -import 'log.dart'; -import 'open_factory.dart'; -import 'powersync_update_notification.dart'; -import 'schema.dart'; -import 'schema_logic.dart'; -import 'streaming_sync.dart'; -import 'sync_status.dart'; - -/// A PowerSync managed database. -/// -/// Use one instance per database file. -/// -/// Use [PowerSyncDatabase.connect] to connect to the PowerSync service, -/// to keep the local database in sync with the remote database. -/// -/// All changes to local tables are automatically recorded, whether connected -/// or not. Once connected, the changes are uploaded. -class PowerSyncDatabase with SqliteQueries implements SqliteConnection { - /// Schema used for the local database. - Schema schema; - - /// The underlying database. - /// - /// For the most part, behavior is the same whether querying on the underlying - /// database, or on [PowerSyncDatabase]. The main difference is in update notifications: - /// the underlying database reports updates to the underlying tables, while - /// [PowerSyncDatabase] reports updates to the higher-level views. - final SqliteDatabase database; - - /// Current connection status. - SyncStatus currentStatus = const SyncStatus(); - - /// Use this stream to subscribe to connection status updates. - late final Stream statusStream; - - final StreamController _statusStreamController = - StreamController.broadcast(); - - /// Broadcast stream that is notified of any table updates. - /// - /// Unlike in [SqliteDatabase.updates], the tables reported here are the - /// higher-level views as defined in the [Schema], and exclude the low-level - /// PowerSync tables. - @override - late final Stream updates; - - /// Delay between retrying failed requests. - /// Defaults to 5 seconds. - /// Only has an effect if changed before calling [connect]. - Duration retryDelay = const Duration(seconds: 5); - - late Future _initialized; - - /// null when disconnected, present when connecting or connected - AbortController? _disconnecter; - - /// Use to prevent multiple connections from being opened concurrently - final Mutex _connectMutex = Mutex(); - - /// The Logger used by this [PowerSyncDatabase]. - /// - /// The default is [autoLogger], which logs to the console in debug builds. - /// Use [debugLogger] to always log to the console. - /// Use [attachedLogger] to propagate logs to [Logger.root] for custom logging. - late final Logger logger; - - Map? clientParams; - - /// Open a [PowerSyncDatabase]. - /// - /// Only a single [PowerSyncDatabase] per [path] should be opened at a time. - /// - /// The specified [schema] is used for the database. - /// - /// A connection pool is used by default, allowing multiple concurrent read - /// transactions, and a single concurrent write transaction. Write transactions - /// do not block read transactions, and read transactions will see the state - /// from the last committed write transaction. - /// - /// A maximum of [maxReaders] concurrent read transactions are allowed. - /// - /// [logger] defaults to [autoLogger], which logs to the console in debug builds. - factory PowerSyncDatabase( - {required Schema schema, - required String path, - int maxReaders = SqliteDatabase.defaultMaxReaders, - Logger? logger, - @Deprecated("Use [PowerSyncDatabase.withFactory] instead.") - // ignore: deprecated_member_use_from_same_package - SqliteConnectionSetup? sqliteSetup}) { - // ignore: deprecated_member_use_from_same_package - var factory = PowerSyncOpenFactory(path: path, sqliteSetup: sqliteSetup); - return PowerSyncDatabase.withFactory(factory, - schema: schema, logger: logger); - } - - /// Open a [PowerSyncDatabase] with a [PowerSyncOpenFactory]. - /// - /// The factory determines which database file is opened, as well as any - /// additional logic to run inside the database isolate before or after opening. - /// - /// Subclass [PowerSyncOpenFactory] to add custom logic to this process. - /// - /// [logger] defaults to [autoLogger], which logs to the console in debug builds. - factory PowerSyncDatabase.withFactory( - PowerSyncOpenFactory openFactory, { - required Schema schema, - int maxReaders = SqliteDatabase.defaultMaxReaders, - Logger? logger, - }) { - final db = SqliteDatabase.withFactory(openFactory, maxReaders: maxReaders); - return PowerSyncDatabase.withDatabase( - schema: schema, database: db, logger: logger); - } - - /// Open a PowerSyncDatabase on an existing [SqliteDatabase]. - /// - /// Migrations are run on the database when this constructor is called. - /// - /// [logger] defaults to [autoLogger], which logs to the console in debug builds. - PowerSyncDatabase.withDatabase({ - required this.schema, - required this.database, - Logger? logger, - }) { - if (logger != null) { - this.logger = logger; - } else { - this.logger = autoLogger; - } - - updates = database.updates - .map((update) => - PowerSyncUpdateNotification.fromUpdateNotification(update)) - .where((update) => update.isNotEmpty) - .cast(); - _initialized = _init(); - } - - Future _init() async { - statusStream = _statusStreamController.stream; - await database.initialize(); - await database.execute('SELECT powersync_init()'); - await updateSchema(schema); - await _updateHasSynced(); - } - - /// Replace the schema with a new version. - /// This is for advanced use cases - typically the schema should just be - /// specified once in the constructor. - /// - /// Cannot be used while connected - this should only be called before [connect]. - Future updateSchema(Schema schema) async { - if (_disconnecter != null) { - throw AssertionError('Cannot update schema while connected'); - } - this.schema = schema; - await updateSchemaInIsolate(database, schema); - } - - /// Wait for initialization to complete. - /// - /// While initializing is automatic, this helps to catch and report initialization errors. - Future initialize() { - return _initialized; - } - - Future _updateHasSynced() async { - const syncedSQL = - 'SELECT 1 FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1'; - - // Query the database to see if any data has been synced. - final result = await database.execute(syncedSQL); - final hasSynced = result.rows.isNotEmpty; - - if (hasSynced != currentStatus.hasSynced) { - final status = SyncStatus(hasSynced: hasSynced); - _setStatus(status); - } - } - - /// - /// returns a [Future] which will resolve once the first full sync has completed. - /// - Future waitForFirstSync() async { - if (currentStatus.hasSynced ?? false) { - return; - } - await for (final result in statusStream) { - if (result.hasSynced ?? false) { - break; - } - } - } - - @override - bool get closed { - return database.closed; - } - - /// Connect to the PowerSync service, and keep the databases in sync. - /// - /// The connection is automatically re-opened if it fails for any reason. - /// - /// Status changes are reported on [statusStream]. - Future connect( - {required PowerSyncBackendConnector connector, - - /// Throttle time between CRUD operations - /// Defaults to 10 milliseconds. - Duration crudThrottleTime = const Duration(milliseconds: 10), - Map? params}) async { - Zone current = Zone.current; - - Future reconnect() { - return _connectMutex.lock(() => _connect( - connector: connector, - crudThrottleTime: crudThrottleTime, - // The reconnect function needs to run in the original zone, - // to avoid recursive lock errors. - reconnect: current.bindCallback(reconnect), - params: params)); - } - - await reconnect(); - } - - Future _connect( - {required PowerSyncBackendConnector connector, - required Duration crudThrottleTime, - required Future Function() reconnect, - Map? params}) async { - clientParams = params; - await initialize(); - - // Disconnect if connected - await disconnect(); - final disconnector = AbortController(); - _disconnecter = disconnector; - - await _initialized; - final dbref = database.isolateConnectionFactory(); - ReceivePort rPort = ReceivePort(); - StreamSubscription? updateSubscription; - rPort.listen((data) async { - if (data is List) { - String action = data[0]; - if (action == "getCredentials") { - await (data[1] as PortCompleter).handle(() async { - final token = await connector.getCredentialsCached(); - logger.fine('Credentials: $token'); - return token; - }); - } else if (action == "invalidateCredentials") { - logger.fine('Refreshing credentials'); - await (data[1] as PortCompleter).handle(() async { - await connector.prefetchCredentials(); - }); - } else if (action == 'init') { - SendPort port = data[1]; - var throttled = - UpdateNotification.throttleStream(updates, crudThrottleTime); - updateSubscription = throttled.listen((event) { - port.send(['update']); - }); - disconnector.onAbort.then((_) { - port.send(['close']); - }).ignore(); - } else if (action == 'uploadCrud') { - await (data[1] as PortCompleter).handle(() async { - await connector.uploadData(this); - }); - } else if (action == 'status') { - final SyncStatus status = data[1]; - _setStatus(status); - } else if (action == 'close') { - // Clear status apart from lastSyncedAt - _setStatus(SyncStatus(lastSyncedAt: currentStatus.lastSyncedAt)); - rPort.close(); - updateSubscription?.cancel(); - } else if (action == 'log') { - LogRecord record = data[1]; - logger.log( - record.level, record.message, record.error, record.stackTrace); - } - } - }); - - var errorPort = ReceivePort(); - errorPort.listen((message) async { - // Sample error: - // flutter: [PowerSync] WARNING: 2023-06-28 16:34:11.566122: Sync Isolate error - // flutter: [Connection closed while receiving data, #0 IOClient.send. (package:http/src/io_client.dart:76:13) - // #1 Stream.handleError. (dart:async/stream.dart:929:16) - // #2 _HandleErrorStream._handleError (dart:async/stream_pipe.dart:269:17) - // #3 _ForwardingStreamSubscription._handleError (dart:async/stream_pipe.dart:157:13) - // #4 _HttpClientResponse.listen. (dart:_http/http_impl.dart:707:16) - // ... - logger.severe('Sync Isolate error', message); - - // Reconnect - // Use the param like this instead of directly calling connect(), to avoid recursive - // locks in some edge cases. - reconnect(); - }); - - disconnected() { - _disconnecter?.completeAbort(); - _disconnecter = null; - rPort.close(); - // Clear status apart from lastSyncedAt - _setStatus(SyncStatus(lastSyncedAt: currentStatus.lastSyncedAt)); - } - - var exitPort = ReceivePort(); - exitPort.listen((message) { - logger.fine('Sync Isolate exit'); - disconnected(); - }); - - if (_disconnecter?.aborted == true) { - disconnected(); - return; - } - - Isolate.spawn( - _powerSyncDatabaseIsolate, - _PowerSyncDatabaseIsolateArgs( - rPort.sendPort, dbref, retryDelay, clientParams), - debugName: 'PowerSyncDatabase', - onError: errorPort.sendPort, - onExit: exitPort.sendPort); - } - - void _setStatus(SyncStatus status) { - if (status != currentStatus) { - currentStatus = status.copyWith( - // 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. - hasSynced: status.lastSyncedAt != null - ? true - : status.hasSynced ?? currentStatus.hasSynced); - _statusStreamController.add(currentStatus); - } - } - - /// Close the sync connection. - /// - /// Use [connect] to connect again. - Future disconnect() async { - if (_disconnecter != null) { - await _disconnecter!.abort(); - } - } - - /// Disconnect and clear the database. - /// - /// Use this when logging out. - /// - /// The database can still be queried after this is called, but the tables - /// would be empty. - /// - /// To preserve data in local-only tables, set [clearLocal] to false. - Future disconnectAndClear({bool clearLocal = true}) async { - await disconnect(); - - await writeTransaction((tx) async { - await tx.execute('DELETE FROM ps_oplog'); - await tx.execute('DELETE FROM ps_crud'); - await tx.execute('DELETE FROM ps_buckets'); - await tx.execute('DELETE FROM ps_untyped'); - - final tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*'; - final existingTableRows = await tx.getAll( - "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ?", - [tableGlob]); - - for (var row in existingTableRows) { - await tx.execute('DELETE FROM ${quoteIdentifier(row['name'])}'); - } - }); - } - - @Deprecated('Use [disconnectAndClear] instead.') - Future disconnectedAndClear() async { - await disconnectAndClear(); - } - - /// Whether a connection to the PowerSync service is currently open. - bool get connected { - return currentStatus.connected; - } - - /// A connection factory that can be passed to different isolates. - /// - /// Use this to access the database in background isolates. - IsolateConnectionFactory isolateConnectionFactory() { - return database.isolateConnectionFactory(); - } - - /// Get upload queue size estimate and count. - Future getUploadQueueStats( - {bool includeSize = false}) async { - if (includeSize) { - final row = await getOptional( - 'SELECT SUM(cast(data as blob) + 20) as size, count(*) as count FROM ps_crud'); - return UploadQueueStats( - count: row?['count'] ?? 0, size: row?['size'] ?? 0); - } else { - final row = await getOptional('SELECT count(*) as count FROM ps_crud'); - return UploadQueueStats(count: row?['count'] ?? 0); - } - } - - /// Get a batch of crud data to upload. - /// - /// Returns null if there is no data to upload. - /// - /// Use this from the [PowerSyncBackendConnector.uploadData]` callback. - /// - /// Once the data have been successfully uploaded, call [CrudBatch.complete] before - /// requesting the next batch. - /// - /// Use [limit] to specify the maximum number of updates to return in a single - /// batch. - /// - /// This method does include transaction ids in the result, but does not group - /// data by transaction. One batch may contain data from multiple transactions, - /// and a single transaction may be split over multiple batches. - Future getCrudBatch({limit = 100}) async { - final rows = await getAll( - 'SELECT id, tx_id, data FROM ps_crud ORDER BY id ASC LIMIT ?', - [limit + 1]); - List all = [for (var row in rows) CrudEntry.fromRow(row)]; - - var haveMore = false; - if (all.length > limit) { - all.removeLast(); - haveMore = true; - } - if (all.isEmpty) { - return null; - } - 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 = $writeCheckpoint WHERE name=\'\$local\''); - } else { - await db.execute( - 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); - } - }); - }); - } - - /// Get the next recorded transaction to upload. - /// - /// Returns null if there is no data to upload. - /// - /// Use this from the [PowerSyncBackendConnector.uploadData]` callback. - /// - /// Once the data have been successfully uploaded, call [CrudTransaction.complete] before - /// requesting the next transaction. - /// - /// 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 int? txId = first['tx_id']; - 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)]; - } - - 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 = $writeCheckpoint WHERE name=\'\$local\''); - } else { - await db.execute( - 'UPDATE ps_buckets SET target_op = $maxOpId WHERE name=\'\$local\''); - } - }); - }); - }); - } - - /// Close the database, releasing resources. - /// - /// Also [disconnect]s any active connection. - /// - /// Once close is called, this connection cannot be used again - a new one - /// must be constructed. - @override - Future close() async { - // Don't close in the middle of the initialization process. - await _initialized; - // Disconnect any active sync connection. - await disconnect(); - // Now we can close the database - await database.close(); - } - - /// Takes a read lock, without starting a transaction. - /// - /// In most cases, [readTransaction] should be used instead. - @override - Future readLock(Future Function(SqliteReadContext tx) callback, - {String? debugContext, Duration? lockTimeout}) async { - await _initialized; - return database.readLock(callback, - debugContext: debugContext, lockTimeout: lockTimeout); - } - - /// Takes a global lock, without starting a transaction. - /// - /// In most cases, [writeTransaction] should be used instead. - @override - Future writeLock(Future Function(SqliteWriteContext tx) callback, - {String? debugContext, Duration? lockTimeout}) async { - await _initialized; - return database.writeLock(callback, - debugContext: debugContext, lockTimeout: lockTimeout); - } - - @override - Stream watch(String sql, - {List parameters = const [], - Duration throttle = const Duration(milliseconds: 30), - Iterable? triggerOnTables}) { - if (triggerOnTables == null || triggerOnTables.isEmpty) { - return super.watch(sql, parameters: parameters, throttle: throttle); - } - List powersyncTables = []; - for (String tableName in triggerOnTables) { - powersyncTables.add(tableName); - powersyncTables.add(_prefixTableNames(tableName, 'ps_data__')); - powersyncTables.add(_prefixTableNames(tableName, 'ps_data_local__')); - } - return super.watch(sql, - parameters: parameters, - throttle: throttle, - triggerOnTables: powersyncTables); - } - - @override - Future getAutoCommit() { - return database.getAutoCommit(); - } - - String _prefixTableNames(String tableName, String prefix) { - String prefixedString = tableName.replaceRange(0, 0, prefix); - return prefixedString; - } -} - -class _PowerSyncDatabaseIsolateArgs { - final SendPort sPort; - final IsolateConnectionFactory dbRef; - final Duration retryDelay; - final Map? parameters; - - _PowerSyncDatabaseIsolateArgs( - this.sPort, this.dbRef, this.retryDelay, this.parameters); -} - -Future _powerSyncDatabaseIsolate( - _PowerSyncDatabaseIsolateArgs args) async { - final sPort = args.sPort; - ReceivePort rPort = ReceivePort(); - StreamController updateController = StreamController.broadcast(); - final upstreamDbClient = args.dbRef.upstreamPort.open(); - - CommonDatabase? db; - final mutex = args.dbRef.mutex.open(); - StreamingSyncImplementation? openedStreamingSync; - - rPort.listen((message) async { - if (message is List) { - String action = message[0]; - if (action == 'update') { - updateController.add('update'); - } else if (action == 'close') { - // This prevents any further transactions being opened, which would - // eventually terminate the sync loop. - await mutex.close(); - db?.dispose(); - db = null; - updateController.close(); - upstreamDbClient.close(); - // Abort any open http requests, and wait for it to be closed properly - await openedStreamingSync?.abort(); - // No kill the Isolate - Isolate.current.kill(); - } - } - }); - Isolate.current.addOnExitListener(sPort, response: const ['close']); - sPort.send(["init", rPort.sendPort]); - - // Is there a way to avoid the overhead if logging is not enabled? - // This only takes effect in this isolate. - isolateLogger.level = Level.ALL; - isolateLogger.onRecord.listen((record) { - var copy = LogRecord(record.level, record.message, record.loggerName, - record.error, record.stackTrace); - sPort.send(["log", copy]); - }); - - Future loadCredentials() async { - final r = IsolateResult(); - sPort.send(["getCredentials", r.completer]); - return r.future; - } - - Future invalidateCredentials() async { - final r = IsolateResult(); - sPort.send(["invalidateCredentials", r.completer]); - return r.future; - } - - Future uploadCrud() async { - final r = IsolateResult(); - sPort.send(["uploadCrud", r.completer]); - return r.future; - } - - runZonedGuarded(() async { - db = args.dbRef.openFactory - .open(SqliteOpenOptions(primaryConnection: false, readOnly: false)); - - final storage = BucketStorage(db!, mutex: mutex); - final sync = StreamingSyncImplementation( - adapter: storage, - credentialsCallback: loadCredentials, - invalidCredentialsCallback: invalidateCredentials, - uploadCrud: uploadCrud, - updateStream: updateController.stream, - retryDelay: args.retryDelay, - syncParameters: args.parameters); - - openedStreamingSync = sync; - sync.streamingSync(); - sync.statusStream.listen((event) { - sPort.send(['status', event]); - }); - - Timer? updateDebouncer; - Set updatedTables = {}; - - void maybeFireUpdates() { - if (updatedTables.isNotEmpty) { - upstreamDbClient.fire(UpdateNotification(updatedTables)); - updatedTables.clear(); - updateDebouncer?.cancel(); - updateDebouncer = null; - } - } - - db!.updates.listen((event) { - updatedTables.add(event.tableName); - - updateDebouncer ??= - Timer(const Duration(milliseconds: 10), maybeFireUpdates); - }); - }, (error, stack) { - // Properly dispose the database if an uncaught error occurs. - // Unfortunately, this does not handle disposing while the database is opening. - // This should be rare - any uncaught error is a bug. And in most cases, - // it should occur after the database is already open. - db?.dispose(); - db = null; - mutex.close(); - throw error; - }); -} +export 'package:powersync/src/database/powersync_database.dart'; diff --git a/packages/powersync/lib/src/powersync_update_notification.dart b/packages/powersync/lib/src/powersync_update_notification.dart index 701ba3d9..b97a27ce 100644 --- a/packages/powersync/lib/src/powersync_update_notification.dart +++ b/packages/powersync/lib/src/powersync_update_notification.dart @@ -1,5 +1,4 @@ import 'package:sqlite_async/sqlite_async.dart'; - import 'schema_logic.dart'; class PowerSyncUpdateNotification extends UpdateNotification { diff --git a/packages/powersync/lib/src/schema_logic.dart b/packages/powersync/lib/src/schema_logic.dart index e3c62908..93296c5a 100644 --- a/packages/powersync/lib/src/schema_logic.dart +++ b/packages/powersync/lib/src/schema_logic.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'schema.dart'; @@ -10,14 +9,14 @@ const String maxOpId = '9223372036854775807'; final invalidSqliteCharacters = RegExp(r'''["'%,\.#\s\[\]]'''); /// Sync the schema to the local database. -void updateSchema(CommonDatabase db, Schema schema) { - db.execute('SELECT powersync_replace_schema(?)', [jsonEncode(schema)]); +Future updateSchema(SqliteWriteContext tx, Schema schema) async { + await tx.execute('SELECT powersync_replace_schema(?)', [jsonEncode(schema)]); } Future updateSchemaInIsolate( SqliteConnection database, Schema schema) async { - await database.computeWithDatabase((db) async { - updateSchema(db, schema); + await database.writeTransaction((tx) async { + await updateSchema(tx, schema); }); } diff --git a/packages/powersync/lib/src/stream_utils.dart b/packages/powersync/lib/src/stream_utils.dart index 4b7ab741..37aee207 100644 --- a/packages/powersync/lib/src/stream_utils.dart +++ b/packages/powersync/lib/src/stream_utils.dart @@ -57,8 +57,12 @@ Stream mergeStreams(List> streams) { Stream ndjson(ByteStream input) { final textInput = input.transform(convert.utf8.decoder); final lineInput = textInput.transform(const convert.LineSplitter()); - final jsonInput = lineInput.transform(StreamTransformer.fromHandlers( - handleData: (String data, EventSink sink) { + final jsonInput = lineInput.transform( + StreamTransformer.fromHandlers(handleError: (error, stackTrace, sink) { + /// On Web if the connection is closed, this error will throw, but + /// the stream is never closed. This closes the stream on error. + sink.close(); + }, handleData: (String data, EventSink sink) { sink.add(convert.jsonDecode(data)); })); return jsonInput; diff --git a/packages/powersync/lib/src/streaming_sync.dart b/packages/powersync/lib/src/streaming_sync.dart index 5bc4d0a5..28f77c53 100644 --- a/packages/powersync/lib/src/streaming_sync.dart +++ b/packages/powersync/lib/src/streaming_sync.dart @@ -1,11 +1,10 @@ import 'dart:async'; import 'dart:convert' as convert; -import 'dart:io'; - import 'package:http/http.dart' as http; import 'package:powersync/src/abort_controller.dart'; import 'package:powersync/src/exceptions.dart'; import 'package:powersync/src/log_internal.dart'; +import 'package:sqlite_async/mutex.dart'; import 'bucket_storage.dart'; import 'connector.dart'; @@ -25,14 +24,14 @@ class StreamingSyncImplementation { final Future Function() uploadCrud; - late http.Client _client; - final Stream updateStream; final StreamController _statusStreamController = StreamController.broadcast(); late final Stream statusStream; + late final http.Client _client; + final StreamController _localPingController = StreamController.broadcast(); @@ -46,6 +45,8 @@ class StreamingSyncImplementation { bool _safeToClose = true; + final Mutex syncMutex, crudMutex; + StreamingSyncImplementation( {required this.adapter, required this.credentialsCallback, @@ -53,8 +54,15 @@ class StreamingSyncImplementation { required this.uploadCrud, required this.updateStream, required this.retryDelay, - this.syncParameters}) { - _client = http.Client(); + this.syncParameters, + required http.Client client, + + /// A unique identifier for this streaming sync implementation + /// A good value is typically the DB file path which it will mutate when syncing. + String? identifier = "unknown"}) + : syncMutex = Mutex(identifier: "sync-$identifier"), + crudMutex = Mutex(identifier: "crud-$identifier") { + _client = client; statusStream = _statusStreamController.stream; } @@ -101,8 +109,10 @@ class StreamingSyncImplementation { await invalidCredentialsCallback!(); invalidCredentials = false; } - await streamingSyncIteration(); - // Continue immediately + // Protect sync iterations with exclusivity (if a valid Mutex is provided) + await syncMutex.lock( + () => streamingSyncIteration(abortController: _abort), + timeout: retryDelay); } catch (e, stacktrace) { if (aborted && e is http.ClientException) { // Explicit abort requested - ignore. Example error: @@ -155,21 +165,23 @@ class StreamingSyncImplementation { } Future uploadCrudBatch() async { - if (adapter.hasCrud()) { - _updateStatus(uploading: true); - await uploadCrud(); - return false; - } else { - // This isolate is the only one triggering - final updated = await adapter.updateLocalTarget(() async { - return getWriteCheckpoint(); - }); - if (updated) { - _localPingController.add(null); - } + return crudMutex.lock(() async { + if ((await adapter.hasCrud())) { + _updateStatus(uploading: true); + await uploadCrud(); + return false; + } else { + // This isolate is the only one triggering + final updated = await adapter.updateLocalTarget(() async { + return getWriteCheckpoint(); + }); + if (updated) { + _localPingController.add(null); + } - return true; - } + return true; + } + }, timeout: retryDelay); } Future getWriteCheckpoint() async { @@ -181,7 +193,6 @@ class StreamingSyncImplementation { final response = await _client.get(uri, headers: { 'Content-Type': 'application/json', - 'User-Id': credentials.userId ?? '', 'Authorization': "Token ${credentials.token}" }); if (response.statusCode == 401) { @@ -226,9 +237,10 @@ class StreamingSyncImplementation { _statusStreamController.add(newStatus); } - Future streamingSyncIteration() async { + Future streamingSyncIteration( + {AbortController? abortController}) async { adapter.startSession(); - final bucketEntries = adapter.getBucketStates(); + final bucketEntries = await adapter.getBucketStates(); Map initialBucketStates = {}; @@ -387,7 +399,6 @@ class StreamingSyncImplementation { final request = http.Request('POST', uri); request.headers['Content-Type'] = 'application/json'; - request.headers['User-Id'] = credentials.userId ?? ''; request.headers['Authorization'] = "Token ${credentials.token}"; request.body = convert.jsonEncode(data); @@ -427,7 +438,7 @@ class StreamingSyncImplementation { String _syncErrorMessage(Object? error) { if (error == null) { return 'Unknown'; - } else if (error is HttpException) { + } else if (error is http.ClientException) { return 'Sync service error'; } else if (error is SyncResponseException) { if (error.statusCode == 401) { @@ -435,8 +446,6 @@ String _syncErrorMessage(Object? error) { } else { return 'Sync service error'; } - } else if (error is SocketException) { - return 'Connection error'; } else if (error is ArgumentError || error is FormatException) { return 'Configuration error'; } else if (error is CredentialsException) { diff --git a/packages/powersync/lib/src/web/powersync_db.worker.dart b/packages/powersync/lib/src/web/powersync_db.worker.dart new file mode 100644 index 00000000..28a8e11a --- /dev/null +++ b/packages/powersync/lib/src/web/powersync_db.worker.dart @@ -0,0 +1,34 @@ +/// This file needs to be compiled to JavaScript with the command +/// dart compile js -O4 packages/powersync/lib/src/web/powersync_db.worker.dart -o assets/db_worker.js +/// The output should then be included in each project's `web` directory + +library; + +import 'dart:js_interop'; + +import 'package:sqlite_async/sqlite3_web_worker.dart'; +import 'package:sqlite_async/sqlite3_web.dart'; +import 'package:sqlite_async/sqlite3_wasm.dart'; + +import 'worker_utils.dart'; + +void main() { + WebSqlite.workerEntrypoint(controller: PowerSyncAsyncSqliteController()); +} + +final class PowerSyncAsyncSqliteController extends AsyncSqliteController { + @override + Future openDatabase( + WasmSqlite3 sqlite3, String path, String vfs) async { + final db = sqlite3.open(path, vfs: vfs); + setupPowerSyncDatabase(db); + + return AsyncSqliteDatabase(database: db); + } + + @override + Future handleCustomRequest( + ClientConnection connection, JSAny? request) { + throw UnimplementedError(); + } +} diff --git a/packages/powersync/lib/src/web/worker_utils.dart b/packages/powersync/lib/src/web/worker_utils.dart new file mode 100644 index 00000000..9f15547f --- /dev/null +++ b/packages/powersync/lib/src/web/worker_utils.dart @@ -0,0 +1,40 @@ +import 'package:powersync/sqlite3_common.dart'; +import 'package:powersync/src/open_factory/common_db_functions.dart'; +import 'package:uuid/uuid.dart'; + +// Registers custom SQLite functions for the SQLite connection +void setupPowerSyncDatabase(CommonDatabase database) { + setupCommonDBFunctions(database); + final uuid = Uuid(); + + database.createFunction( + functionName: 'uuid', + argumentCount: const AllowedArgumentCount(0), + function: (args) { + return uuid.v4(); + }, + ); + database.createFunction( + // Postgres compatibility + functionName: 'gen_random_uuid', + argumentCount: const AllowedArgumentCount(0), + function: (args) => uuid.v4(), + ); + database.createFunction( + functionName: 'powersync_sleep', + argumentCount: const AllowedArgumentCount(1), + function: (args) { + // Can't perform synchronous sleep on web + final millis = args[0] as int; + return millis; + }, + ); + + database.createFunction( + functionName: 'powersync_connection_name', + argumentCount: const AllowedArgumentCount(0), + function: (args) { + return 'N/A'; + }, + ); +} diff --git a/packages/powersync/lib/web_worker.dart b/packages/powersync/lib/web_worker.dart new file mode 100644 index 00000000..19983751 --- /dev/null +++ b/packages/powersync/lib/web_worker.dart @@ -0,0 +1 @@ +export 'src/web/powersync_db.worker.dart'; diff --git a/packages/powersync/pubspec.yaml b/packages/powersync/pubspec.yaml index 3dd149e1..c351f275 100644 --- a/packages/powersync/pubspec.yaml +++ b/packages/powersync/pubspec.yaml @@ -1,5 +1,5 @@ name: powersync -version: 1.5.5 +version: 1.6.0-alpha.1 homepage: https://powersync.com repository: https://github.com/powersync-ja/powersync.dart description: PowerSync Flutter SDK - keep PostgreSQL databases in sync with on-device SQLite databases. @@ -11,22 +11,29 @@ dependencies: sdk: flutter sqlite_async: ^0.8.1 + + universal_io: ^2.0.0 sqlite3_flutter_libs: ^0.5.23 powersync_flutter_libs: ^0.1.0 + meta: ^1.0.0 http: ^1.1.0 uuid: ^4.2.0 async: ^2.10.0 logging: ^1.1.1 collection: ^1.17.0 - + fetch_client: ^1.1.2 + js: ^0.7.0 dev_dependencies: + dcli: ^4.0.0 lints: ^3.0.0 test: ^1.25.0 test_api: ^0.7.0 path_provider: ^2.0.13 - sqlite3: ^2.3.0 + sqlite3: "^2.4.4" shelf: ^1.4.1 shelf_router: ^1.1.4 + shelf_static: ^1.1.2 + stream_channel: ^2.1.2 path: ^1.8.3 platforms: @@ -35,3 +42,4 @@ platforms: linux: macos: windows: + web: diff --git a/packages/powersync/test/bucket_storage_test.dart b/packages/powersync/test/bucket_storage_test.dart index 4ae26b26..6cf57613 100644 --- a/packages/powersync/test/bucket_storage_test.dart +++ b/packages/powersync/test/bucket_storage_test.dart @@ -1,12 +1,13 @@ import 'package:powersync/powersync.dart'; import 'package:powersync/src/bucket_storage.dart'; import 'package:powersync/src/sync_types.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; -import 'package:sqlite_async/mutex.dart'; import 'package:sqlite_async/sqlite3_common.dart'; import 'package:test/test.dart'; -import 'util.dart'; +import 'utils/abstract_test_utils.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); const putAsset1_1 = OplogEntry( opId: '1', @@ -41,17 +42,19 @@ const removeAsset1_5 = OplogEntry( void main() { group('Bucket Storage Tests', () { late PowerSyncDatabase powersync; - late CommonDatabase db; late BucketStorage bucketStorage; late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); - powersync = await setupPowerSync(path: path); - db = await setupSqlite(powersync: powersync); - bucketStorage = BucketStorage(db, mutex: Mutex()); + powersync = await testUtils.setupPowerSync(path: path); + bucketStorage = BucketStorage(powersync); + }); + + tearDown(() async { + await powersync.close(); }); Future syncLocalChecked(Checkpoint checkpoint) async { @@ -59,26 +62,30 @@ void main() { expect(result, equals(SyncLocalDatabaseResult(ready: true))); } - void expectAsset1_3() { + Future expectAsset1_3() async { expect( - db.select("SELECT id, description, make FROM assets WHERE id = 'O1'"), + await powersync.execute( + "SELECT id, description, make FROM assets WHERE id = 'O1'"), equals([ {'id': 'O1', 'description': 'bard', 'make': null} ])); } - void expectNoAsset1() { + Future expectNoAsset1() async { expect( - db.select("SELECT id, description, make FROM assets WHERE id = 'O1'"), + await powersync.execute( + "SELECT id, description, make FROM assets WHERE id = 'O1'"), equals([])); } - void expectNoAssets() { - expect(db.select("SELECT id, description, make FROM assets"), equals([])); + Future expectNoAssets() async { + expect( + await powersync.execute("SELECT id, description, make FROM assets"), + equals([])); } test('Basic Setup', () async { - expect(bucketStorage.getBucketStates(), equals([])); + expect(await bucketStorage.getBucketStates(), equals([])); await bucketStorage.saveSyncData(SyncDataBatch([ SyncBucketData( @@ -87,14 +94,15 @@ void main() { ) ])); - expect(bucketStorage.getBucketStates(), + final bucketStates = await bucketStorage.getBucketStates(); + expect(bucketStates, equals([const BucketState(bucket: 'bucket1', opId: '3')])); await syncLocalChecked(Checkpoint( lastOpId: '3', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should get an object from multiple buckets', () async { @@ -111,7 +119,7 @@ void main() { BucketChecksum(bucket: 'bucket2', checksum: 3) ])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should prioritize later updates', () async { @@ -129,7 +137,7 @@ void main() { BucketChecksum(bucket: 'bucket2', checksum: 1) ])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should ignore a remove from one bucket', () async { @@ -144,7 +152,7 @@ void main() { BucketChecksum(bucket: 'bucket2', checksum: 7) ])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should remove when removed from all buckets', () async { @@ -159,7 +167,7 @@ void main() { BucketChecksum(bucket: 'bucket2', checksum: 7) ])); - expectNoAssets(); + await expectNoAssets(); }); test('should use subkeys', () async { @@ -192,7 +200,8 @@ void main() { ])); expect( - db.select("SELECT id, description, make FROM assets WHERE id = 'O1'"), + await powersync.execute( + "SELECT id, description, make FROM assets WHERE id = 'O1'"), equals([ {'id': 'O1', 'description': 'B', 'make': null} ])); @@ -205,7 +214,7 @@ void main() { BucketChecksum(bucket: 'bucket1', checksum: 13), ])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should fail checksum validation', () async { @@ -227,7 +236,7 @@ void main() { checkpointValid: false, checkpointFailures: ['bucket1', 'bucket2']))); - expectNoAssets(); + await expectNoAssets(); }); test('should delete buckets', () async { @@ -250,12 +259,12 @@ void main() { ])); // Bucket is deleted, but object is still present in other buckets. - expectAsset1_3(); + await expectAsset1_3(); await bucketStorage.removeBuckets(['bucket1']); await syncLocalChecked(Checkpoint(lastOpId: '3', checksums: [])); // Both buckets deleted - object removed. - expectNoAssets(); + await expectNoAssets(); }); test('should delete and re-create buckets', () async { @@ -292,12 +301,12 @@ void main() { await syncLocalChecked(Checkpoint(lastOpId: '3', checksums: [ BucketChecksum(bucket: 'bucket1', checksum: 4), ])); - expectAsset1_3(); + await expectAsset1_3(); // Now final delete await bucketStorage.removeBuckets(['bucket1']); await syncLocalChecked(Checkpoint(lastOpId: '3', checksums: [])); - expectNoAssets(); + await expectNoAssets(); }); test('should handle MOVE', () async { @@ -332,7 +341,7 @@ void main() { lastOpId: '3', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 4)])); - expectAsset1_3(); + await expectAsset1_3(); }); test('should handle CLEAR', () async { @@ -370,9 +379,10 @@ void main() { // 2 + 3. 1 is replaced with 2. checksums: [BucketChecksum(bucket: 'bucket1', checksum: 5)])); - expectNoAsset1(); + await expectNoAsset1(); expect( - db.select("SELECT id, description FROM assets WHERE id = 'O2'"), + await powersync + .execute("SELECT id, description FROM assets WHERE id = 'O2'"), equals([ {'id': 'O2', 'description': 'bar'} ])); @@ -382,13 +392,13 @@ void main() { // Test case where a type is added to the schema after we already have the data. // Re-initialize with empty database - await cleanDb(path: path); + await testUtils.cleanDb(path: path); - powersync = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), + powersync = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), schema: const Schema([])); await powersync.initialize(); - db = await setupSqlite(powersync: powersync); - bucketStorage = BucketStorage(db, mutex: Mutex()); + bucketStorage = BucketStorage(powersync); await bucketStorage.saveSyncData(SyncDataBatch([ SyncBucketData( @@ -400,20 +410,20 @@ void main() { await syncLocalChecked(Checkpoint( lastOpId: '4', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); - expect( - () => db.select('SELECT * FROM assets'), + + await expectLater(() async { + await powersync.execute('SELECT * FROM assets'); + }, throwsA((e) => - e is sqlite.SqliteException && - e.message.contains('no such table'))); + e is SqliteException && e.message.contains('no such table'))); await powersync.close(); // Now open another instance with new schema - powersync = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: schema); - db = await setupSqlite(powersync: powersync); - - expectAsset1_3(); + powersync = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: defaultSchema); + await expectAsset1_3(); }); test('should remove types', () async { @@ -428,26 +438,27 @@ void main() { lastOpId: '3', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); - expectAsset1_3(); + await expectAsset1_3(); await powersync.close(); // Now open another instance with new schema - powersync = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), + powersync = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), schema: const Schema([])); - db = await setupSqlite(powersync: powersync); - expect( - () => db.select('SELECT * FROM assets'), + + await expectLater( + () async => await powersync.execute('SELECT * FROM assets'), throwsA((e) => - e is sqlite.SqliteException && - e.message.contains('no such table'))); + e is SqliteException && e.message.contains('no such table'))); + + await powersync.close(); // Add schema again - powersync = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), + powersync = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), schema: schema); - db = await setupSqlite(powersync: powersync); - - expectAsset1_3(); + await expectAsset1_3(); }); test('should compact', () async { @@ -471,7 +482,7 @@ void main() { writeCheckpoint: '4', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 7)])); - final stats = db.select( + final stats = await powersync.execute( 'SELECT row_type as type, row_id as id, count(*) as count FROM ps_oplog GROUP BY row_type, row_id ORDER BY row_type, row_id'); expect( stats, @@ -494,9 +505,9 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local save - db.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); + powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); expect( - db.select('SELECT id FROM assets WHERE id = \'O3\''), + await powersync.execute('SELECT id FROM assets WHERE id = \'O3\''), equals([ {'id': 'O3'} ])); @@ -508,7 +519,7 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); expect(result, equals(SyncLocalDatabaseResult(ready: false))); - final batch = bucketStorage.getCrudBatch(); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { return '4'; @@ -523,7 +534,7 @@ void main() { // The data must still be present locally. expect( - db.select('SELECT id FROM assets WHERE id = \'O3\''), + await powersync.execute('SELECT id FROM assets WHERE id = \'O3\''), equals([ {'id': 'O3'} ])); @@ -539,7 +550,8 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Since the object was not in the sync response, it is deleted. - expect(db.select('SELECT id FROM assets WHERE id = \'O3\''), equals([])); + expect(await powersync.execute('SELECT id FROM assets WHERE id = \'O3\''), + equals([])); }); test( @@ -558,9 +570,9 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local save - db.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); + powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); - final batch = bucketStorage.getCrudBatch(); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { return '4'; @@ -576,7 +588,7 @@ void main() { SyncDataBatch([SyncBucketData(bucket: 'bucket1', data: [])])); // Add more data before syncLocalDatabase. - db.execute('INSERT INTO assets(id) VALUES(?)', ['O4']); + powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O4']); final result4 = await bucketStorage.syncLocalDatabase(Checkpoint( lastOpId: '5', @@ -601,11 +613,11 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local save - db.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); - final batch = bucketStorage.getCrudBatch(); + await powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); + final batch = await bucketStorage.getCrudBatch(); // Add more data before the complete() call - db.execute('INSERT INTO assets(id) VALUES(?)', ['O4']); + await powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O4']); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { return '4'; @@ -640,8 +652,8 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local save - db.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); - final batch = bucketStorage.getCrudBatch(); + powersync.execute('INSERT INTO assets(id) VALUES(?)', ['O3']); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { return '4'; @@ -668,7 +680,8 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 11)])); expect( - db.select('SELECT description FROM assets WHERE id = \'O3\''), + await powersync + .execute('SELECT description FROM assets WHERE id = \'O3\''), equals([ {'description': 'server updated'} ])); @@ -688,16 +701,18 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local insert, later rejected by server - db.execute('INSERT INTO assets(id, description) VALUES(?, ?)', + await powersync.execute( + 'INSERT INTO assets(id, description) VALUES(?, ?)', ['O3', 'inserted']); - final batch = bucketStorage.getCrudBatch(); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { return '4'; }); expect( - db.select('SELECT description FROM assets WHERE id = \'O3\''), + await powersync + .execute('SELECT description FROM assets WHERE id = \'O3\''), equals([ {'description': 'inserted'} ])); @@ -707,7 +722,9 @@ void main() { writeCheckpoint: '4', checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); - expect(db.select('SELECT description FROM assets WHERE id = \'O3\''), + expect( + await powersync + .execute('SELECT description FROM assets WHERE id = \'O3\''), equals([])); }); @@ -725,12 +742,14 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local delete, later rejected by server - db.execute('DELETE FROM assets WHERE id = ?', ['O2']); + await powersync.execute('DELETE FROM assets WHERE id = ?', ['O2']); - expect(db.select('SELECT description FROM assets WHERE id = \'O2\''), + expect( + await powersync + .execute('SELECT description FROM assets WHERE id = \'O2\''), equals([])); // Simulate a permissions error when uploading - data should be preserved. - final batch = bucketStorage.getCrudBatch(); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { @@ -743,7 +762,8 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); expect( - db.select('SELECT description FROM assets WHERE id = \'O2\''), + await powersync + .execute('SELECT description FROM assets WHERE id = \'O2\''), equals([ {'description': 'bar'} ])); @@ -763,16 +783,17 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); // Local update, later rejected by server - db.execute( + await powersync.execute( 'UPDATE assets SET description = ? WHERE id = ?', ['updated', 'O2']); expect( - db.select('SELECT description FROM assets WHERE id = \'O2\''), + await powersync + .execute('SELECT description FROM assets WHERE id = \'O2\''), equals([ {'description': 'updated'} ])); // Simulate a permissions error when uploading - data should be preserved. - final batch = bucketStorage.getCrudBatch(); + final batch = await bucketStorage.getCrudBatch(); await batch!.complete(); await bucketStorage.updateLocalTarget(() async { @@ -785,7 +806,8 @@ void main() { checksums: [BucketChecksum(bucket: 'bucket1', checksum: 6)])); expect( - db.select('SELECT description FROM assets WHERE id = \'O2\''), + await powersync + .execute('SELECT description FROM assets WHERE id = \'O2\''), equals([ {'description': 'bar'} ])); diff --git a/packages/powersync/test/crud_test.dart b/packages/powersync/test/crud_test.dart index a080d98f..b01e71dc 100644 --- a/packages/powersync/test/crud_test.dart +++ b/packages/powersync/test/crud_test.dart @@ -1,9 +1,10 @@ import 'package:powersync/powersync.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:test/test.dart'; -import 'util.dart'; +import 'utils/test_utils_impl.dart'; +final testUtils = TestUtils(); const testId = "2290de4f-0488-4e50-abed-f8e8eb1d0b42"; void main() { @@ -12,10 +13,10 @@ void main() { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); - powersync = await setupPowerSync(path: path); + powersync = await testUtils.setupPowerSync(path: path); }); test('INSERT', () async { @@ -72,7 +73,7 @@ void main() { [testId, 'test3']); }, throwsA((e) => - e is sqlite.SqliteException && + e is SqliteException && e.message.contains('UNIQUE constraint failed'))); }); @@ -132,13 +133,13 @@ void main() { [testId, 'test2', 'test3']); }, throwsA((e) => - e is sqlite.SqliteException && + e is SqliteException && e.message.contains('cannot UPSERT a view'))); }); test('INSERT-only tables', () async { await powersync.disconnectAndClear(); - powersync = await setupPowerSync( + powersync = await testUtils.setupPowerSync( path: path, schema: const Schema([ Table.insertOnly( @@ -154,7 +155,7 @@ void main() { equals([ { 'data': - '{"op":"PUT","type":"logs","id":"$testId","data":{"content":"test log","level":"INFO"}}' + '{"op":"PUT","type":"logs","id":"$testId","data":{"level":"INFO","content":"test log"}}' } ])); @@ -230,7 +231,7 @@ void main() { equals([ { 'data': - '{"op":"PATCH","type":"assets","id":"$testId","data":{"description":"updated","quantity":${bigNumber + 1}}}' + '{"op":"PATCH","type":"assets","id":"$testId","data":{"quantity":${bigNumber + 1},"description":"updated"}}' } ])); }); @@ -250,6 +251,7 @@ void main() { }); var tx1 = (await powersync.getNextCrudTransaction())!; + expect(tx1.transactionId, equals(1)); expect( tx1.crud, diff --git a/packages/powersync/test/disconnect_test.dart b/packages/powersync/test/disconnect_test.dart new file mode 100644 index 00000000..c4c7ad55 --- /dev/null +++ b/packages/powersync/test/disconnect_test.dart @@ -0,0 +1,60 @@ +import 'package:powersync/powersync.dart'; +import 'package:test/test.dart'; +import 'streaming_sync_test.dart'; +import 'utils/test_utils_impl.dart'; +import 'watch_test.dart'; + +final testUtils = TestUtils(); + +void main() { + group('Disconnect Tests', () { + late String path; + + setUp(() async { + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); + }); + + test('Multiple calls to disconnect', () async { + final db = await testUtils.setupPowerSync(path: path, schema: testSchema); + + credentialsCallback() async { + // A blank endpoint will fail, but that's okay for this test + final endpoint = ''; + return PowerSyncCredentials( + endpoint: endpoint, + token: 'token', + userId: 'u1', + expiresAt: DateTime.now()); + } + + db.retryDelay = Duration(milliseconds: 5000); + var connector = TestConnector(credentialsCallback); + await db.connect(connector: connector); + + // Call disconnect multiple times, each Future should resolve + final disconnect1 = db.disconnect(); + final disconnect2 = db.disconnect(); + + await expectLater(disconnect1, completes); + await expectLater(disconnect2, completes); + }); + + test('disconnectAndClear clears DB', () async { + final db = await testUtils.setupPowerSync(path: path, schema: testSchema); + + await db.execute( + 'INSERT INTO customers (id, name, email) VALUES(uuid(), ?, ?)', + ['Steven', 'steven@journeyapps.com']); + + final getCustomersQuery = 'SELECT * from customers'; + final initialCustomers = await db.getAll(getCustomersQuery); + expect(initialCustomers.length, equals(1)); + + await db.disconnectAndClear(); + + final finalCustomers = await db.getAll(getCustomersQuery); + expect(finalCustomers.length, equals(0)); + }); + }); +} diff --git a/packages/powersync/test/offline_online_test.dart b/packages/powersync/test/offline_online_test.dart index ba27d0eb..d270f48a 100644 --- a/packages/powersync/test/offline_online_test.dart +++ b/packages/powersync/test/offline_online_test.dart @@ -3,7 +3,9 @@ import 'dart:convert'; import 'package:powersync/powersync.dart'; import 'package:test/test.dart'; -import 'util.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); const assetId = "2290de4f-0488-4e50-abed-f8e8eb1d0b42"; const userId = "3390de4f-0488-4e50-abed-f8e8eb1d0b42"; @@ -72,14 +74,15 @@ void main() { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); }); test('Switch from offline-only to online', () async { // Start with "offline-only" schema. // This does not record any operations to the crud queue. - final db = await setupPowerSync(path: path, schema: makeSchema(false)); + final db = + await testUtils.setupPowerSync(path: path, schema: makeSchema(false)); await db.execute('INSERT INTO customers(id, name, email) VALUES(?, ?, ?)', [customerId, 'test customer', 'test@example.org']); diff --git a/packages/powersync/test/performance_native_test.dart b/packages/powersync/test/performance_native_test.dart new file mode 100644 index 00000000..9def5945 --- /dev/null +++ b/packages/powersync/test/performance_native_test.dart @@ -0,0 +1,119 @@ +@TestOn('!browser') +import 'package:powersync/powersync.dart'; +import 'package:test/test.dart'; + +import 'performance_shared_test.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +void main() { + group('Performance Tests', () { + late String path; + + setUp(() async { + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); + }); + + tearDown(() async { + // await cleanDb(path: path); + }); + + // Manual tests + test('Insert Performance 3a - computeWithDatabase', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + await db.computeWithDatabase((db) async { + for (var i = 0; i < 1000; i++) { + db.execute( + 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', + ['Test User', 'user@example.org']); + } + }); + + print("Completed synchronous inserts in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + + test('Insert Performance 3b - prepared statement', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + await db.computeWithDatabase((db) async { + var stmt = db.prepare( + 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)'); + try { + for (var i = 0; i < 1000; i++) { + stmt.execute(['Test User', 'user@example.org']); + } + } finally { + stmt.dispose(); + } + }); + + print("Completed synchronous inserts prepared in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + + test('Insert Performance 3c - prepared statement, dart-generated ids', + () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + // Test to exclude the function overhead time of generating uuids + final timer = Stopwatch()..start(); + + await db.computeWithDatabase((db) async { + var ids = List.generate(1000, (index) => uuid.v4()); + var stmt = db + .prepare('INSERT INTO customers(id, name, email) VALUES(?, ?, ?)'); + try { + for (var id in ids) { + stmt.execute([id, 'Test User', 'user@example.org']); + } + } finally { + stmt.dispose(); + } + }); + + print("Completed synchronous inserts prepared in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + test('Insert Performance 3d - prepared statement, pre-generated ids', + () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + // Test to completely exclude time taken to generate uuids + var ids = List.generate(1000, (index) => uuid.v4()); + + final timer = Stopwatch()..start(); + + await db.computeWithDatabase((db) async { + var stmt = db + .prepare('INSERT INTO customers(id, name, email) VALUES(?, ?, ?)'); + for (var id in ids) { + stmt.execute([id, 'Test User', 'user@example.org']); + } + stmt.dispose(); + }); + + print("Completed synchronous inserts prepared in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + }); +} diff --git a/packages/powersync/test/performance_shared_test.dart b/packages/powersync/test/performance_shared_test.dart new file mode 100644 index 00000000..5044c099 --- /dev/null +++ b/packages/powersync/test/performance_shared_test.dart @@ -0,0 +1,110 @@ +import 'package:powersync/powersync.dart'; +import 'package:test/test.dart'; + +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +const pschema = Schema([ + Table.localOnly('assets', [ + Column.text('created_at'), + Column.text('make'), + Column.text('model'), + Column.text('serial_number'), + Column.integer('quantity'), + Column.text('user_id'), + Column.text('customer_id'), + Column.text('description'), + ]), + Table.localOnly('customers', [Column.text('name'), Column.text('email')]) +]); + +void main() { + group('Performance Tests', () { + late String path; + + setUp(() async { + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); + }); + + tearDown(() async { + // await cleanDb(path: path); + }); + + // Manual tests + test('Insert Performance 1 - direct', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + for (var i = 0; i < 1000; i++) { + await db.execute( + 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', + ['Test User', 'user@example.org']); + } + print("Completed sequential inserts in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + + test('Insert Performance 2 - writeTransaction', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + await db.writeTransaction((tx) async { + for (var i = 0; i < 1000; i++) { + await tx.execute( + 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', + ['Test User', 'user@example.org']); + } + }); + print("Completed transaction inserts in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + + test('Insert Performance 4 - pipelined', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + await db.writeTransaction((tx) async { + List futures = []; + for (var i = 0; i < 1000; i++) { + var future = tx.execute( + 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', + ['Test User', 'user@example.org']); + futures.add(future); + } + await Future.wait(futures); + }); + print("Completed pipelined inserts in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + + test('Insert Performance 5 - executeBatch', () async { + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: pschema); + await db.initialize(); + final timer = Stopwatch()..start(); + + var parameters = List.generate( + 1000, (index) => [uuid.v4(), 'Test user', 'user@example.org']); + await db.executeBatch( + 'INSERT INTO customers(id, name, email) VALUES(?, ?, ?)', parameters); + print("Completed executeBatch in ${timer.elapsed}"); + expect(await db.get('SELECT count(*) as count FROM customers'), + equals({'count': 1000})); + }); + }); +} diff --git a/packages/powersync/test/performance_test.dart b/packages/powersync/test/performance_test.dart deleted file mode 100644 index 61a5bb1e..00000000 --- a/packages/powersync/test/performance_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:powersync/powersync.dart'; -import 'package:test/test.dart'; - -import 'util.dart'; - -const pschema = Schema([ - Table.localOnly('assets', [ - Column.text('created_at'), - Column.text('make'), - Column.text('model'), - Column.text('serial_number'), - Column.integer('quantity'), - Column.text('user_id'), - Column.text('customer_id'), - Column.text('description'), - ]), - Table.localOnly('customers', [Column.text('name'), Column.text('email')]) -]); - -void main() { - group('Performance Tests', () { - late String path; - - setUp(() async { - path = dbPath(); - await cleanDb(path: path); - }); - - tearDown(() async { - // await cleanDb(path: path); - }); - - // Manual tests - test('Insert Performance 1 - direct', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - for (var i = 0; i < 1000; i++) { - await db.execute( - 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', - ['Test User', 'user@example.org']); - } - print("Completed sequential inserts in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 2 - writeTransaction', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - await db.writeTransaction((tx) async { - for (var i = 0; i < 1000; i++) { - await tx.execute( - 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', - ['Test User', 'user@example.org']); - } - }); - print("Completed transaction inserts in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 3a - computeWithDatabase', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - await db.computeWithDatabase((db) async { - for (var i = 0; i < 1000; i++) { - db.execute( - 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', - ['Test User', 'user@example.org']); - } - }); - - print("Completed synchronous inserts in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 3b - prepared statement', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - await db.computeWithDatabase((db) async { - var stmt = db.prepare( - 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)'); - try { - for (var i = 0; i < 1000; i++) { - stmt.execute(['Test User', 'user@example.org']); - } - } finally { - stmt.dispose(); - } - }); - - print("Completed synchronous inserts prepared in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 3c - prepared statement, dart-generated ids', - () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - // Test to exclude the function overhead time of generating uuids - final timer = Stopwatch()..start(); - - await db.computeWithDatabase((db) async { - var ids = List.generate(1000, (index) => uuid.v4()); - var stmt = db - .prepare('INSERT INTO customers(id, name, email) VALUES(?, ?, ?)'); - try { - for (var id in ids) { - stmt.execute([id, 'Test User', 'user@example.org']); - } - } finally { - stmt.dispose(); - } - }); - - print("Completed synchronous inserts prepared in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - test('Insert Performance 3d - prepared statement, pre-generated ids', - () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - // Test to completely exclude time taken to generate uuids - var ids = List.generate(1000, (index) => uuid.v4()); - - final timer = Stopwatch()..start(); - - await db.computeWithDatabase((db) async { - var stmt = db - .prepare('INSERT INTO customers(id, name, email) VALUES(?, ?, ?)'); - for (var id in ids) { - stmt.execute([id, 'Test User', 'user@example.org']); - } - stmt.dispose(); - }); - - print("Completed synchronous inserts prepared in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 4 - pipelined', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - await db.writeTransaction((tx) async { - List futures = []; - for (var i = 0; i < 1000; i++) { - var future = tx.execute( - 'INSERT INTO customers(id, name, email) VALUES(uuid(), ?, ?)', - ['Test User', 'user@example.org']); - futures.add(future); - } - await Future.wait(futures); - }); - print("Completed pipelined inserts in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - - test('Insert Performance 5 - executeBatch', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: pschema); - await db.initialize(); - final timer = Stopwatch()..start(); - - var parameters = List.generate( - 1000, (index) => [uuid.v4(), 'Test user', 'user@example.org']); - await db.executeBatch( - 'INSERT INTO customers(id, name, email) VALUES(?, ?, ?)', parameters); - print("Completed executeBatch in ${timer.elapsed}"); - expect(await db.get('SELECT count(*) as count FROM customers'), - equals({'count': 1000})); - }); - }); -} diff --git a/packages/powersync/test/powersync_test.dart b/packages/powersync/test/powersync_native_test.dart similarity index 54% rename from packages/powersync/test/powersync_test.dart rename to packages/powersync/test/powersync_native_test.dart index 4bac8121..aeda23d8 100644 --- a/packages/powersync/test/powersync_test.dart +++ b/packages/powersync/test/powersync_native_test.dart @@ -1,27 +1,30 @@ +@TestOn('!browser') import 'dart:async'; import 'dart:math'; import 'package:powersync/powersync.dart'; -import 'package:sqlite_async/mutex.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:test/test.dart'; -import 'package:sqlite_async/sqlite3.dart' as sqlite; -import 'util.dart'; +import 'utils/abstract_test_utils.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); void main() { group('Basic Tests', () { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); }); tearDown(() async { - await cleanDb(path: path); + await testUtils.cleanDb(path: path); }); test('Basic Setup', () async { - final db = await setupPowerSync(path: path); + final db = await testUtils.setupPowerSync(path: path); await db.execute( 'INSERT INTO assets(id, make) VALUES(uuid(), ?)', ['Test Make']); final result = await db.get('SELECT make FROM assets'); @@ -40,8 +43,10 @@ void main() { // Manual test test('Concurrency', () async { - final db = PowerSyncDatabase.withFactory(TestOpenFactory(path: path), - schema: schema, maxReaders: 3); + final db = PowerSyncDatabase.withFactory( + await testUtils.testFactory(path: path), + schema: defaultSchema, + maxReaders: 3); await db.initialize(); print("${DateTime.now()} start"); @@ -54,7 +59,7 @@ void main() { }); test('read-only transactions', () async { - final db = await setupPowerSync(path: path); + final db = await testUtils.setupPowerSync(path: path); // Can read await db.getAll("WITH test AS (SELECT 1 AS one) SELECT * FROM test"); @@ -64,7 +69,7 @@ void main() { await db.getAll('INSERT INTO assets(id) VALUES(?)', ['test']); }, throwsA((e) => - e is sqlite.SqliteException && + e is SqliteException && e.message .contains('attempt to write in a read-only transaction'))); @@ -77,7 +82,7 @@ void main() { "WITH test AS (SELECT 1 AS one) INSERT INTO assets(id) SELECT one FROM test"); }, throwsA((e) => - e is sqlite.SqliteException && + e is SqliteException && e.message .contains('attempt to write in a read-only transaction'))); @@ -88,66 +93,10 @@ void main() { }); }); - test('should not allow direct db calls within a transaction callback', - () async { - final db = await setupPowerSync(path: path); - - await db.writeTransaction((tx) async { - await expectLater(() async { - await db.execute('INSERT INTO assets(id) VALUES(?)', ['test']); - }, throwsA((e) => e is LockError && e.message.contains('tx.execute'))); - }); - }); - - test('should not allow read-only db calls within transaction callback', - () async { - final db = await setupPowerSync(path: path); - - await db.writeTransaction((tx) async { - // This uses a different connection, so it _could_ work. - // But it's likely unintentional and could cause weird bugs, so we don't - // allow it by default. - await expectLater(() async { - await db.getAll('SELECT * FROM assets'); - }, throwsA((e) => e is LockError && e.message.contains('tx.getAll'))); - }); - - await db.readTransaction((tx) async { - // This does actually attempt a lock on the same connection, so it - // errors. - // This also exposes an interesting test case where the read transaction - // opens another connection, but doesn't use it. - await expectLater(() async { - await db.getAll('SELECT * FROM assets'); - }, throwsA((e) => e is LockError && e.message.contains('tx.getAll'))); - }); - }); - - test('should not allow read-only db calls within lock callback', () async { - final db = await setupPowerSync(path: path); - // Locks - should behave the same as transactions above - - await db.writeLock((tx) async { - await expectLater(() async { - await db.getOptional('SELECT * FROM assets'); - }, - throwsA( - (e) => e is LockError && e.message.contains('tx.getOptional'))); - }); - - await db.readLock((tx) async { - await expectLater(() async { - await db.getOptional('SELECT * FROM assets'); - }, - throwsA( - (e) => e is LockError && e.message.contains('tx.getOptional'))); - }); - }); - test( 'should allow read-only db calls within transaction callback in separate zone', () async { - final db = await setupPowerSync(path: path); + final db = await testUtils.setupPowerSync(path: path); // Get a reference to the parent zone (outside the transaction). final zone = Zone.current; @@ -181,12 +130,5 @@ void main() { // }); // }); }); - - test('should allow PRAMGAs', () async { - final db = await setupPowerSync(path: path); - // Not allowed in transactions, but does work as a direct statement. - await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); - await db.execute('VACUUM'); - }); }); } diff --git a/packages/powersync/test/powersync_shared_test.dart b/packages/powersync/test/powersync_shared_test.dart new file mode 100644 index 00000000..3cb3a9c3 --- /dev/null +++ b/packages/powersync/test/powersync_shared_test.dart @@ -0,0 +1,83 @@ +import 'package:sqlite_async/mutex.dart'; +import 'package:test/test.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +void main() { + group('Basic Tests', () { + late String path; + + setUp(() async { + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); + }); + + tearDown(() async { + await testUtils.cleanDb(path: path); + }); + + test('should not allow direct db calls within a transaction callback', + () async { + final db = await testUtils.setupPowerSync(path: path); + + await db.writeTransaction((tx) async { + await expectLater(() async { + await db.execute('INSERT INTO assets(id) VALUES(?)', ['test']); + }, throwsA((e) => e is LockError && e.message.contains('tx.execute'))); + }); + }); + + test('should not allow read-only db calls within transaction callback', + () async { + final db = await testUtils.setupPowerSync(path: path); + + await db.writeTransaction((tx) async { + // This uses a different connection, so it _could_ work. + // But it's likely unintentional and could cause weird bugs, so we don't + // allow it by default. + await expectLater(() async { + await db.getAll('SELECT * FROM assets'); + }, throwsA((e) => e is LockError && e.message.contains('tx.getAll'))); + }); + + await db.readTransaction((tx) async { + // This does actually attempt a lock on the same connection, so it + // errors. + // This also exposes an interesting test case where the read transaction + // opens another connection, but doesn't use it. + await expectLater(() async { + await db.getAll('SELECT * FROM assets'); + }, throwsA((e) => e is LockError && e.message.contains('tx.getAll'))); + }); + }); + + test('should not allow read-only db calls within lock callback', () async { + final db = await testUtils.setupPowerSync(path: path); + // Locks - should behave the same as transactions above + + await db.writeLock((tx) async { + await expectLater(() async { + await db.getOptional('SELECT * FROM assets'); + }, + throwsA( + (e) => e is LockError && e.message.contains('tx.getOptional'))); + }); + + await db.readLock((tx) async { + await expectLater(() async { + await db.getOptional('SELECT * FROM assets'); + }, + throwsA( + (e) => e is LockError && e.message.contains('tx.getOptional'))); + }); + }); + + test('should allow PRAMGAs', () async { + final db = await testUtils.setupPowerSync(path: path); + // Not allowed in transactions, but does work as a direct statement. + await db.execute('PRAGMA wal_checkpoint(TRUNCATE)'); + await db.execute('VACUUM'); + }); + }); +} diff --git a/packages/powersync/test/schema_test.dart b/packages/powersync/test/schema_test.dart index 613808ce..f6a1bf55 100644 --- a/packages/powersync/test/schema_test.dart +++ b/packages/powersync/test/schema_test.dart @@ -1,7 +1,9 @@ import 'package:powersync/powersync.dart'; import 'package:test/test.dart'; -import 'util.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); const testId = "2290de4f-0488-4e50-abed-f8e8eb1d0b42"; final schema = Schema([ @@ -28,15 +30,16 @@ void main() { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); }); test('Schema versioning', () async { // Test that powersync_replace_schema() is a no-op when the schema is not // modified. - final powersync = await setupPowerSync(path: path, schema: schema); + final powersync = + await testUtils.setupPowerSync(path: path, schema: schema); final versionBefore = await powersync.get('PRAGMA schema_version'); await powersync.updateSchema(schema); @@ -106,8 +109,10 @@ void main() { greaterThan(versionAfter2['schema_version'])); }); + /// The assets table is locked after performing the EXPLAIN QUERY test('Indexing', () async { - final powersync = await setupPowerSync(path: path, schema: schema); + final powersync = + await testUtils.setupPowerSync(path: path, schema: schema); final results = await powersync.execute( 'EXPLAIN QUERY PLAN SELECT * FROM assets WHERE make = ?', ['test']); diff --git a/packages/powersync/test/server/asset_server.dart b/packages/powersync/test/server/asset_server.dart new file mode 100644 index 00000000..272e0bfa --- /dev/null +++ b/packages/powersync/test/server/asset_server.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_static/shelf_static.dart'; +import 'package:stream_channel/stream_channel.dart'; + +const _corsHeaders = {'Access-Control-Allow-Origin': '*'}; + +Middleware cors() { + Response? handleOptionsRequest(Request request) { + if (request.method == 'OPTIONS') { + return Response.ok(null, headers: _corsHeaders); + } else { + // Returning null will run the regular request handler + return null; + } + } + + Response addCorsHeaders(Response response) { + return response.change(headers: _corsHeaders); + } + + return createMiddleware( + requestHandler: handleOptionsRequest, responseHandler: addCorsHeaders); +} + +Future hybridMain(StreamChannel channel) async { + final server = await HttpServer.bind('localhost', 0); + + final handler = const Pipeline() + .addMiddleware(cors()) + .addHandler(createStaticHandler('./assets')); + io.serveRequests(server, handler); + + channel.sink.add(server.port); + await channel.stream + .listen(null) + .asFuture() + .then((_) => server.close()); +} diff --git a/packages/powersync/test/server/worker_server.dart b/packages/powersync/test/server/worker_server.dart new file mode 100644 index 00000000..c34debc9 --- /dev/null +++ b/packages/powersync/test/server/worker_server.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:dcli/dcli.dart'; +import 'package:path/path.dart' as p; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_static/shelf_static.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'asset_server.dart'; + +Future hybridMain(StreamChannel channel) async { + final assetsDirectory = p.normalize( + p.join(DartScript.self.pathToScriptDirectory, '../../../../assets')); + + // Copy sqlite3.wasm file expected by the worker + final sqliteOutputPath = p.join(assetsDirectory, 'sqlite3.wasm'); + + if (!(await File(sqliteOutputPath).exists())) { + throw AssertionError( + 'sqlite3.wasm file should be present in the root assets folder'); + } + + final workerOutputPath = p.join(assetsDirectory, 'powersync_db.worker.js'); + + if (!(await File(workerOutputPath).exists())) { + throw AssertionError( + 'powersync_db.worker.js file should be present in the ./assets folder'); + } + + final server = await HttpServer.bind('localhost', 0); + + final handler = const Pipeline() + .addMiddleware(cors()) + .addHandler(createStaticHandler(assetsDirectory)); + io.serveRequests(server, handler); + + channel.sink.add(server.port); + await channel.stream.listen(null).asFuture().then((_) async { + print('closing server'); + await server.close(); + }); +} diff --git a/packages/powersync/test/stream_test.dart b/packages/powersync/test/stream_test.dart index 050d199c..412711f4 100644 --- a/packages/powersync/test/stream_test.dart +++ b/packages/powersync/test/stream_test.dart @@ -1,3 +1,4 @@ +@TestOn('!browser') import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/powersync/test/streaming_sync_test.dart b/packages/powersync/test/streaming_sync_test.dart index 52bf6acd..6d1e9e0d 100644 --- a/packages/powersync/test/streaming_sync_test.dart +++ b/packages/powersync/test/streaming_sync_test.dart @@ -1,3 +1,5 @@ +@TestOn('!browser') +// TODO setup hybrid server import 'dart:async'; import 'dart:math'; @@ -5,7 +7,9 @@ import 'package:powersync/powersync.dart'; import 'package:test/test.dart'; import 'test_server.dart'; -import 'util.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); class TestConnector extends PowerSyncBackendConnector { final Function _fetchCredentials; @@ -26,12 +30,12 @@ void main() { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); }); tearDown(() async { - await cleanDb(path: path); + await testUtils.cleanDb(path: path); }); test('full powersync reconnect', () async { @@ -47,7 +51,7 @@ void main() { endpoint: server.endpoint, token: 'token'); } - final pdb = await setupPowerSync(path: path); + final pdb = await testUtils.setupPowerSync(path: path); pdb.retryDelay = Duration(milliseconds: 5000); var connector = TestConnector(credentialsCallback); pdb.connect(connector: connector); @@ -94,7 +98,7 @@ void main() { return PowerSyncCredentials(endpoint: server.endpoint, token: 'token'); } - final pdb = await setupPowerSync(path: path); + final pdb = await testUtils.setupPowerSync(path: path); pdb.retryDelay = const Duration(milliseconds: 5); var connector = TestConnector(credentialsCallback); pdb.connect(connector: connector); @@ -121,7 +125,7 @@ void main() { return PowerSyncCredentials(endpoint: server.endpoint, token: 'token'); } - final pdb = await setupPowerSync(path: path); + final pdb = await testUtils.setupPowerSync(path: path); pdb.retryDelay = Duration(milliseconds: 5000); var connector = TestConnector(credentialsCallback); pdb.connect(connector: connector); diff --git a/packages/powersync/test/utils/abstract_test_utils.dart b/packages/powersync/test/utils/abstract_test_utils.dart new file mode 100644 index 00000000..eee5683f --- /dev/null +++ b/packages/powersync/test/utils/abstract_test_utils.dart @@ -0,0 +1,92 @@ +import 'package:logging/logging.dart'; +import 'package:powersync/powersync.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:test_api/src/backend/invoker.dart'; + +const schema = Schema([ + Table('assets', [ + Column.text('created_at'), + Column.text('make'), + Column.text('model'), + Column.text('serial_number'), + Column.integer('quantity'), + Column.text('user_id'), + Column.text('customer_id'), + Column.text('description'), + ], indexes: [ + Index('makemodel', [IndexedColumn('make'), IndexedColumn('model')]) + ]), + Table('customers', [Column.text('name'), Column.text('email')]) +]); + +const defaultSchema = schema; + +final testLogger = _makeTestLogger(); + +Logger _makeTestLogger() { + final logger = Logger.detached('PowerSync Tests'); + logger.level = Level.ALL; + logger.onRecord.listen((record) { + print( + '[${record.loggerName}] ${record.level.name}: ${record.time}: ${record.message}'); + if (record.error != null) { + print(record.error); + } + if (record.stackTrace != null) { + print(record.stackTrace); + } + + if (record.error != null && record.level >= Level.SEVERE) { + // 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!; + } + + uncaughtError(); + } + }); + return logger; +} + +abstract class AbstractTestUtils { + String dbPath() { + final test = Invoker.current!.liveTest; + var testName = test.test.name; + var testShortName = + testName.replaceAll(RegExp(r'[\s\./]'), '_').toLowerCase(); + var dbName = "test-db/$testShortName.db"; + return dbName; + } + + /// Generates a test open factory + Future testFactory( + {String? path, + String sqlitePath = '', + SqliteOptions options = const SqliteOptions.defaults()}) async { + return PowerSyncOpenFactory(path: path ?? dbPath(), sqliteOptions: options); + } + + /// Creates a SqliteDatabaseConnection + Future setupPowerSync( + {String? path, Schema? schema}) async { + final db = PowerSyncDatabase.withFactory(await testFactory(path: path), + schema: schema ?? defaultSchema, logger: testLogger); + await db.initialize(); + return db; + } + + Future setupSqlite( + {required PowerSyncDatabase powersync}) async { + await powersync.initialize(); + + final sqliteDb = + await powersync.isolateConnectionFactory().openRawDatabase(); + + return sqliteDb; + } + + /// Deletes any DB data + Future cleanDb({required String path}); +} diff --git a/packages/powersync/test/utils/native_test_utils.dart b/packages/powersync/test/utils/native_test_utils.dart new file mode 100644 index 00000000..353679e7 --- /dev/null +++ b/packages/powersync/test/utils/native_test_utils.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'package:powersync/powersync.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:sqlite3/open.dart' as sqlite_open; + +import 'abstract_test_utils.dart'; + +const defaultSqlitePath = 'libsqlite3.so.0'; + +class TestOpenFactory extends PowerSyncOpenFactory { + TestOpenFactory({required super.path}); + + @override + CommonDatabase open(SqliteOpenOptions options) { + sqlite_open.open.overrideFor(sqlite_open.OperatingSystem.linux, () { + return DynamicLibrary.open('libsqlite3.so.0'); + }); + sqlite_open.open.overrideFor(sqlite_open.OperatingSystem.macOS, () { + return DynamicLibrary.open('libsqlite3.dylib'); + }); + return super.open(options); + } + + @override + String getLibraryForPlatform({String? path = "."}) { + switch (Abi.current()) { + case Abi.androidArm: + case Abi.androidArm64: + case Abi.androidX64: + return '$path/libpowersync.so'; + case Abi.macosArm64: + case Abi.macosX64: + return '$path/libpowersync.dylib'; + case Abi.linuxX64: + return '$path/libpowersync.so'; + case Abi.windowsArm64: + case Abi.windowsX64: + return '$path/powersync.dll'; + case Abi.androidIA32: + throw PowersyncNotReadyException( + 'Unsupported processor architecture. X86 Android emulators are not ' + 'supported. Please use an x86_64 emulator instead. All physical ' + 'Android devices are supported including 32bit ARM.', + ); + default: + throw PowersyncNotReadyException( + 'Unsupported processor architecture "${Abi.current()}". ' + 'Please open an issue on GitHub to request it.', + ); + } + } +} + +class TestUtils extends AbstractTestUtils { + @override + String dbPath() { + Directory("test-db").createSync(recursive: false); + return super.dbPath(); + } + + @override + Future cleanDb({required String path}) async { + try { + await File(path).delete(); + } on PathNotFoundException { + // Not an issue + } + try { + await File("$path-shm").delete(); + } on PathNotFoundException { + // Not an issue + } + try { + await File("$path-wal").delete(); + } on PathNotFoundException { + // Not an issue + } + } + + @override + Future testFactory( + {String? path, + String sqlitePath = defaultSqlitePath, + SqliteOptions options = const SqliteOptions.defaults()}) async { + return TestOpenFactory(path: path ?? dbPath()); + } +} diff --git a/packages/powersync/test/utils/stub_test_utils.dart b/packages/powersync/test/utils/stub_test_utils.dart new file mode 100644 index 00000000..3f86512c --- /dev/null +++ b/packages/powersync/test/utils/stub_test_utils.dart @@ -0,0 +1,8 @@ +import 'abstract_test_utils.dart'; + +class TestUtils extends AbstractTestUtils { + @override + Future cleanDb({required String path}) { + throw UnimplementedError(); + } +} diff --git a/packages/powersync/test/utils/test_utils_impl.dart b/packages/powersync/test/utils/test_utils_impl.dart new file mode 100644 index 00000000..99a34d39 --- /dev/null +++ b/packages/powersync/test/utils/test_utils_impl.dart @@ -0,0 +1,5 @@ +export 'stub_test_utils.dart' + // ignore: uri_does_not_exist + if (dart.library.io) 'native_test_utils.dart' + // ignore: uri_does_not_exist + if (dart.library.html) 'web_test_utils.dart'; diff --git a/packages/powersync/test/utils/web_test_utils.dart b/packages/powersync/test/utils/web_test_utils.dart new file mode 100644 index 00000000..41c43f7e --- /dev/null +++ b/packages/powersync/test/utils/web_test_utils.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:html'; + +import 'package:js/js.dart'; +import 'package:powersync/powersync.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:test/test.dart'; +import 'abstract_test_utils.dart'; + +@JS('URL.createObjectURL') +external String _createObjectURL(Blob blob); + +class TestUtils extends AbstractTestUtils { + late Future _isInitialized; + late final String sqlite3WASMUri; + late final String workerUri; + + TestUtils() { + _isInitialized = _init(); + } + + Future _init() async { + final channel = + spawnHybridUri('/test/server/worker_server.dart', stayAlive: true); + final port = await channel.stream.first as int; + sqlite3WASMUri = 'http://localhost:$port/sqlite3.wasm'; + // Cross origin workers are not supported, but we can supply a Blob + final workerUriSource = 'http://localhost:$port/powersync_db.worker.js'; + + final blob = Blob(['importScripts("$workerUriSource");'], + 'application/javascript'); + workerUri = _createObjectURL(blob); + } + + @override + Future cleanDb({required String path}) async {} + + @override + Future testFactory( + {String? path, + String? sqlitePath, + SqliteOptions options = const SqliteOptions.defaults()}) async { + await _isInitialized; + + final webOptions = SqliteOptions( + webSqliteOptions: + WebSqliteOptions(wasmUri: sqlite3WASMUri, workerUri: workerUri)); + return super.testFactory(path: path, options: webOptions); + } + + @override + Future setupPowerSync( + {String? path, Schema? schema}) async { + await _isInitialized; + return super.setupPowerSync(path: path, schema: schema); + } + + @override + Future setupSqlite( + {required PowerSyncDatabase powersync}) async { + await _isInitialized; + return super.setupSqlite(powersync: powersync); + } +} diff --git a/packages/powersync/test/watch_test.dart b/packages/powersync/test/watch_test.dart index 7ccf53da..493f1aa7 100644 --- a/packages/powersync/test/watch_test.dart +++ b/packages/powersync/test/watch_test.dart @@ -1,3 +1,5 @@ +library; + import 'dart:async'; import 'dart:math'; @@ -5,7 +7,9 @@ import 'package:powersync/powersync.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; -import 'util.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); const testSchema = Schema([ Table('assets', [ @@ -29,12 +33,13 @@ void main() { late String path; setUp(() async { - path = dbPath(); - await cleanDb(path: path); + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); }); test('watch', () async { - final powersync = await setupPowerSync(path: path, schema: testSchema); + final powersync = + await testUtils.setupPowerSync(path: path, schema: testSchema); const baseTime = 20; @@ -97,7 +102,8 @@ void main() { }); test('onChange', () async { - final powersync = await setupPowerSync(path: path, schema: testSchema); + final powersync = + await testUtils.setupPowerSync(path: path, schema: testSchema); const baseTime = 20; diff --git a/packages/powersync_attachments_helper/CHANGELOG.md b/packages/powersync_attachments_helper/CHANGELOG.md index 02e52846..ac457a0c 100644 --- a/packages/powersync_attachments_helper/CHANGELOG.md +++ b/packages/powersync_attachments_helper/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.0-alpha.1 + +- Update a dependency to the latest release. + ## 0.5.1 - Upgrade `sqlite_async` to version 0.8.1. @@ -7,6 +11,25 @@ - Upgrade minimum Dart SDK constraint to `3.4.0`. - Upgrade `sqlite_async` to version 0.7.0. +## 0.3.0-alpha.4 + +- Update a dependency to the latest release. + +## 0.3.0-alpha.3 + +- Update a dependency to the latest release. + +## 0.3.0-alpha.2 + +> Note: This release has breaking changes. + +- **FIX**: reset isProcessing when exception is thrown during sync process. (#81). +- **FIX**: attachment queue duplicating requests (#68). +- **FIX**(powersync-attachements-helper): pubspec file (#29). +- **FEAT**(attachments): add error handlers (#65). +- **DOCS**: update readmes (#38). +- **BREAKING** **FEAT**(attachments): cater for subdirectories in storage (#78). + ## 0.4.1 - Reduce version number of `path_provider` to `2.0.13` @@ -31,6 +54,10 @@ - BREAKING CHANGE: `reconcileId` has been removed in favour of `reconcileIds`. This will require a change to `watchIds` implementation which is shown in `example/getting_started.dart` - Improved queue so that uploads, downloads and deletes do not happen multiple times +## 0.3.0-alpha.1 + +- Added initial support for Web platforms + ## 0.2.1 - Added `onUploadError` as an optional function that can be set when setting up the queue to handle upload errors diff --git a/packages/powersync_attachments_helper/lib/src/attachments_queue_table.dart b/packages/powersync_attachments_helper/lib/src/attachments_queue_table.dart index 05570d1f..91e940b5 100644 --- a/packages/powersync_attachments_helper/lib/src/attachments_queue_table.dart +++ b/packages/powersync_attachments_helper/lib/src/attachments_queue_table.dart @@ -1,5 +1,5 @@ import 'package:powersync/powersync.dart'; -import 'package:powersync/sqlite3.dart' as sqlite; +import 'package:powersync/sqlite3_common.dart' as sqlite; const defaultAttachmentsQueueTableName = 'attachments_queue'; diff --git a/packages/powersync_attachments_helper/lib/src/attachments_service.dart b/packages/powersync_attachments_helper/lib/src/attachments_service.dart index 6f9b8da4..0383a393 100644 --- a/packages/powersync_attachments_helper/lib/src/attachments_service.dart +++ b/packages/powersync_attachments_helper/lib/src/attachments_service.dart @@ -2,7 +2,7 @@ import './attachments_queue.dart'; import './attachments_queue_table.dart'; import './local_storage_adapter.dart'; import 'package:powersync/powersync.dart'; -import 'package:sqlite_async/sqlite3.dart'; +import 'package:powersync/sqlite3_common.dart'; /// Service for interacting with the attachment queue. class AttachmentsService { diff --git a/packages/powersync_attachments_helper/pubspec.yaml b/packages/powersync_attachments_helper/pubspec.yaml index 8eee1b69..4b620d8c 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.5.1 +version: 0.6.0-alpha.1 repository: https://github.com/powersync-ja/powersync.dart homepage: https://www.powersync.com/ environment: @@ -10,7 +10,7 @@ dependencies: flutter: sdk: flutter - powersync: ^1.5.5 + powersync: ^1.6.0-alpha.1 logging: ^1.2.0 sqlite_async: ^0.8.1 path_provider: ^2.0.13 diff --git a/pubspec.lock b/pubspec.lock index 8dd75e5a..0ffee54d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -266,7 +266,7 @@ packages: source: hosted version: "2.1.0" path: - dependency: transitive + dependency: "direct dev" description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" @@ -522,4 +522,4 @@ packages: source: hosted version: "2.1.1" sdks: - dart: ">=3.2.3 <4.0.0" + dart: ">=3.3.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8d3b181f..829a05de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,14 @@ name: powersync_dart description: A sample command-line application. version: 1.0.0 -# repository: https://github.com/my_org/my_repo environment: - sdk: ^3.2.3 + sdk: ">=3.3.0 <4.0.0" # Add regular dependencies here. -dependencies: - # path: ^1.8.0 dev_dependencies: lints: ^2.1.1 melos: ^3.4.0 test: ^1.25.0 + path: ^1.0.0 diff --git a/scripts/compile_webworker.dart b/scripts/compile_webworker.dart new file mode 100644 index 00000000..26a75eef --- /dev/null +++ b/scripts/compile_webworker.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +Future main() async { + // This should be the package root + final cwd = Directory.current.absolute.path; + final repoRoot = path.normalize(cwd); + + /// The monorepo root assets directory + final workerFilename = 'powersync_db.worker.js'; + final outputPath = path.join(repoRoot, 'assets/$workerFilename'); + + final workerSourcePath = path.join( + repoRoot, './packages/powersync/lib/src/web/powersync_db.worker.dart'); + + // And compile worker code + final process = await Process.run( + Platform.executable, + [ + 'compile', + 'js', + '-o', + outputPath, + '-O4', + workerSourcePath, + ], + workingDirectory: cwd); + + if (process.exitCode != 0) { + throw Exception('Could not compile worker: ${process.stdout.toString()}'); + } + + // Copy this to all demo apps web folders + final demosRoot = path.join(repoRoot, 'demos'); + final demoDirectories = + Directory(demosRoot).listSync().whereType().toList(); + + for (final demoDir in demoDirectories) { + // only if the demo is web enabled + final demoWebDir = path.join(demoDir.absolute.path, 'web'); + if (!Directory(demoWebDir).existsSync()) { + continue; + } + final demoOutputPath = path.join(demoWebDir, workerFilename); + File(outputPath).copySync(demoOutputPath); + } +} diff --git a/scripts/init_powersync_core_binary.dart b/scripts/init_powersync_core_binary.dart new file mode 100644 index 00000000..3bd78ba3 --- /dev/null +++ b/scripts/init_powersync_core_binary.dart @@ -0,0 +1,93 @@ +/// Downloads the powersync dynamic library and copies it to the powersync package directory +/// This is only necessary for running unit tests in the powersync package +import 'dart:ffi'; +import 'dart:io'; + +import 'package:melos/melos.dart'; + +final sqliteUrl = + 'https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v0.1.7'; + +void main() async { + final sqliteCoreFilename = getLibraryForPlatform(); + final powersyncPath = "packages/powersync"; + final sqliteCorePath = '$powersyncPath/$sqliteCoreFilename'; + + // Download dynamic library + await downloadFile("$sqliteUrl/$sqliteCoreFilename", sqliteCorePath); + + final originalFile = File(sqliteCorePath); + + try { + final newFileName = getFileNameForPlatform(); + if (await originalFile.exists()) { + try { + // Rename the original file to the new file name + await originalFile.rename("$powersyncPath/$newFileName"); + print( + 'File renamed successfully from $sqliteCoreFilename to $newFileName'); + } catch (e) { + throw IOException('Error renaming file: $e'); + } + } else { + throw IOException('File $sqliteCoreFilename does not exist.'); + } + } on IOException catch (e) { + print(e.message); + } +} + +String getFileNameForPlatform() { + switch (Abi.current()) { + case Abi.macosArm64: + case Abi.macosX64: + return 'libpowersync.dylib'; + case Abi.linuxX64: + case Abi.linuxArm64: + return 'libpowersync.so'; + case Abi.windowsX64: + return 'powersync.dll'; + default: + throw IOException( + 'Unsupported processor architecture "${Abi.current()}". ' + 'Please open an issue on GitHub to request it.', + ); + } +} + +Future downloadFile(String url, String savePath) async { + print('Downloading: $url'); + var httpClient = HttpClient(); + var request = await httpClient.getUrl(Uri.parse(url)); + var response = await request.close(); + if (response.statusCode == HttpStatus.ok) { + var file = File(savePath); + await response.pipe(file.openWrite()); + } else { + print( + 'Failed to download file: ${response.statusCode} ${response.reasonPhrase}'); + } +} + +String getLibraryForPlatform() { + switch (Abi.current()) { + case Abi.macosArm64: + return 'libpowersync_aarch64.dylib'; + case Abi.macosX64: + return 'libpowersync_x64.dylib'; + case Abi.linuxX64: + return 'libpowersync_x64.so'; + case Abi.linuxArm64: + return 'libpowersync_aarch64.so'; + case Abi.windowsX64: + return 'powersync_x64.dll'; + case Abi.windowsArm64: + throw IOException('ARM64 Windows is not supported. ' + 'Please use an x86_64 Windows machine or open a GitHub issue to request it'); + default: + throw IOException( + 'Unsupported processor architecture "${Abi.current()}". ' + 'Please open an issue on GitHub to request it.', + ); + } +} diff --git a/scripts/init_sqlite_wasm.dart b/scripts/init_sqlite_wasm.dart new file mode 100644 index 00000000..40aa79c0 --- /dev/null +++ b/scripts/init_sqlite_wasm.dart @@ -0,0 +1,44 @@ +/// Downloads sqlite3.wasm and copies it to all demo folders +import 'dart:io'; + +final sqliteUrl = + 'https://github.com/powersync-ja/sqlite3.dart/releases/download/v0.1.0/sqlite3.wasm'; + +void main() async { + // Create assets directory if it doesn't exist + final assetsDir = Directory('assets'); + if (!await assetsDir.exists()) { + await assetsDir.create(); + } + + final sqliteFilename = 'sqlite3.wasm'; + final sqlitePath = 'assets/$sqliteFilename'; + + // Download sqlite3.wasm + await downloadFile(sqliteUrl, sqlitePath); + + await for (var entity in Directory('demos').list()) { + if (entity is Directory) { + var demoDir = entity; + var webDir = Directory('${demoDir.path}/web'); + if (await webDir.exists()) { + await File(sqlitePath).copy('${webDir.path}/$sqliteFilename'); + print('Copied $sqlitePath to ${webDir.path}'); + } + } + } +} + +Future downloadFile(String url, String savePath) async { + print('Downloading: $url'); + var httpClient = HttpClient(); + var request = await httpClient.getUrl(Uri.parse(url)); + var response = await request.close(); + if (response.statusCode == HttpStatus.ok) { + var file = File(savePath); + await response.pipe(file.openWrite()); + } else { + print( + 'Failed to download file: ${response.statusCode} ${response.reasonPhrase}'); + } +}