diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 356835d..aa26a73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,8 @@ name: Compile Assets and Create Draft Release on: push: tags: - # Trigger on tags beginning with 'v' - - 'v*' + # Trigger on sqlite_async tags + - 'sqlite_async-v*' jobs: release: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 85c9c02..0e0d34b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,13 +3,15 @@ name: Test on: push: branches: - - "**" + - "*" + pull_request: jobs: build: runs-on: ubuntu-latest + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - uses: dart-lang/setup-dart@v1 - name: Install Melos @@ -29,26 +31,28 @@ jobs: test: runs-on: ubuntu-latest + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) strategy: + fail-fast: false matrix: include: - sqlite_version: "3440200" sqlite_url: "https://www.sqlite.org/2023/sqlite-autoconf-3440200.tar.gz" - dart_sdk: 3.4.0 + dart_sdk: stable - sqlite_version: "3430200" sqlite_url: "https://www.sqlite.org/2023/sqlite-autoconf-3430200.tar.gz" - dart_sdk: 3.4.0 + dart_sdk: stable - sqlite_version: "3420000" sqlite_url: "https://www.sqlite.org/2023/sqlite-autoconf-3420000.tar.gz" - dart_sdk: 3.4.0 + dart_sdk: stable - sqlite_version: "3410100" sqlite_url: "https://www.sqlite.org/2023/sqlite-autoconf-3410100.tar.gz" - dart_sdk: 3.4.0 + dart_sdk: stable - sqlite_version: "3380000" sqlite_url: "https://www.sqlite.org/2022/sqlite-autoconf-3380000.tar.gz" - dart_sdk: 3.4.0 + dart_sdk: stable steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - uses: dart-lang/setup-dart@v1 with: sdk: ${{ matrix.dart_sdk }} @@ -67,3 +71,4 @@ jobs: run: | export LD_LIBRARY_PATH=$(pwd)/sqlite-autoconf-${{ matrix.sqlite_version }}/.libs melos test + melos test_build diff --git a/.gitignore b/.gitignore index 0bb8318..e0c7669 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ doc *.iml build + +.DS_store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5181152 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,365 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-10-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.12.2`](#sqlite_async---v0122) + - [`drift_sqlite_async` - `v0.2.5`](#drift_sqlite_async---v025) + +--- + +#### `sqlite_async` - `v0.12.2` + + - Add `withAllConnections` method to run statements on all connections in the pool. + +#### `drift_sqlite_async` - `v0.2.5` + + - Allow customizing update notifications from `sqlite_async`. + + +## 2025-08-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.12.1`](#sqlite_async---v0121) + - [`sqlite_async` - `v0.12.0`](#sqlite_async---v0120) + - [`drift_sqlite_async` - `v0.2.3+1`](#drift_sqlite_async---v0231) + +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. + + - `drift_sqlite_async` - `v0.2.3+1` + +--- + +#### `sqlite_async` - `v0.12.1` + +- Fix distributing updates from shared worker. + +#### `sqlite_async` - `v0.12.0` + + - Avoid large transactions creating a large internal update queue. + + +## 2025-07-29 + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.11.8`](#sqlite_async---v0118) + - [`drift_sqlite_async` - `v0.2.3`](#drift_sqlite_async---v023) + +--- + +#### `sqlite_async` - `v0.11.8` + +- Support nested transactions (emulated with `SAVEPOINT` statements). +- Fix web compilation issues with version `2.8.0` of `package:sqlite3`. + +#### `drift_sqlite_async` - `v0.2.3` + +- Support nested transactions. + +## 2025-06-03 + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.11.7`](#sqlite_async---v0117) + +--- + +#### `sqlite_async` - `v0.11.7` + +- Shared worker: Release locks owned by connected client tab when it closes. +- Fix web concurrency issues: Consistently apply a shared mutex or let a shared + worker coordinate access. + +## 2025-05-28 + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.11.6`](#sqlite_async---v0116) + +--- + +#### `sqlite_async` - `v0.11.6` + +- Native: Consistently report errors when opening the database instead of + causing unhandled exceptions. + +## 2025-05-22 + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.11.5`](#sqlite_async---v0115) + +--- + +#### `sqlite_async` - `v0.11.5` + +- Allow profiling queries. Queries are profiled by default in debug and profile builds, the runtime + for queries is added to profiling timelines under the `sqlite_async` tag. +- Fix cancelling `watch()` queries sometimes taking longer than necessary. +- Fix web databases not respecting lock timeouts. + +## 2024-11-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`drift_sqlite_async` - `v0.2.0`](#drift_sqlite_async---v020) + +--- + +#### `drift_sqlite_async` - `v0.2.0` + + - Automatically run Drift migrations + + +## 2024-11-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.11.0`](#sqlite_async---v0110) + - [`drift_sqlite_async` - `v0.2.0-alpha.4`](#drift_sqlite_async---v020-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. + + - `drift_sqlite_async` - `v0.2.0-alpha.4` + +--- + +#### `sqlite_async` - `v0.11.0` + + - Automatically flush IndexedDB storage to fix durability issues + + +## 2024-11-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.10.1`](#sqlite_async---v0101) + - [`drift_sqlite_async` - `v0.2.0-alpha.3`](#drift_sqlite_async---v020-alpha3) + +--- + +#### `sqlite_async` - `v0.10.1` + + - For database setups not using a shared worker, use a `BroadcastChannel` to share updates across different tabs. + +#### `drift_sqlite_async` - `v0.2.0-alpha.3` + + - Bump `sqlite_async` to v0.10.1 + + +## 2024-10-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`drift_sqlite_async` - `v0.2.0-alpha.2`](#drift_sqlite_async---v020-alpha2) + - [`sqlite_async` - `v0.10.0`](#sqlite_async---v0100) + +--- + +#### `drift_sqlite_async` - `v0.2.0-alpha.2` + + - Bump sqlite_async to v0.10.0 + +#### `sqlite_async` - `v0.10.0` + + - Add the `exposeEndpoint()` method available on web databases. It returns a serializable + description of the database endpoint that can be sent across workers. + This allows sharing an opened database connection across workers. + + +## 2024-09-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.9.0`](#sqlite_async---v090) + - [`drift_sqlite_async` - `v0.1.0-alpha.7`](#drift_sqlite_async---v010-alpha7) + +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. + + - `drift_sqlite_async` - `v0.1.0-alpha.7` + +--- + +#### `sqlite_async` - `v0.9.0` + + - Support the latest version of package:web and package:sqlite3_web + + - Export sqlite3 `open` for packages that depend on `sqlite_async` + + +## 2024-08-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.8.3`](#sqlite_async---v083) + - [`drift_sqlite_async` - `v0.1.0-alpha.6`](#drift_sqlite_async---v010-alpha6) + +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. + + - `drift_sqlite_async` - `v0.1.0-alpha.6` + +--- + +#### `sqlite_async` - `v0.8.3` + + - Updated web database implementation for get and getOptional. Fixed refreshSchema not working in web. + + +## 2024-08-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`sqlite_async` - `v0.8.2`](#sqlite_async---v082) + - [`drift_sqlite_async` - `v0.1.0-alpha.5`](#drift_sqlite_async---v010-alpha5) + +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. + + - `drift_sqlite_async` - `v0.1.0-alpha.5` + +--- + +#### `sqlite_async` - `v0.8.2` + + - **FEAT**: Added `refreshSchema()`, allowing queries and watch calls to work against updated schemas. + + +## 2024-07-10 + +### Changes + +--- + +Packages with breaking changes: + +- There are no breaking changes in this release. + +Packages with other changes: + +- [`sqlite_async` - `v0.8.1`](#sqlite_async---v081) +- [`drift_sqlite_async` - `v0.1.0-alpha.4`](#drift_sqlite_async---v010-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. + +#### `drift_sqlite_async` - `v0.1.0-alpha.4` + +- **FEAT**: web support. + +--- + +#### `sqlite_async` - `v0.8.1` + +- **FEAT**: use navigator locks. diff --git a/README.md b/README.md index dbb27aa..e3e15bf 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,4 @@ This monorepo uses [melos](https://melos.invertase.dev/) to handle command and p To configure the monorepo for development run `melos prepare` after cloning. -For detailed usage, check out the inner [sqlite_async](https://github.com/powersync-ja/sqlite_async.dart/tree/main/packages/sqlite_async) and [drift_sqlite_async](https://github.com/powersync-ja/sqlite_async.dart/tree/main/packages/drift_sqlite_async) packages. +For detailed usage, check out the inner [sqlite_async](https://github.com/powersync-ja/sqlite_async.dart/tree/main/packages/sqlite_async) and [drift_sqlite_async](https://github.com/powersync-ja/sqlite_async.dart/tree/main/packages/drift_sqlite_async) packages. \ No newline at end of file diff --git a/melos.yaml b/melos.yaml index 19d45ad..4555e43 100644 --- a/melos.yaml +++ b/melos.yaml @@ -3,6 +3,12 @@ name: sqlite_async_monorepo packages: - packages/** +command: + version: + changelog: false + packageFilters: + noPrivate: true + scripts: prepare: melos bootstrap && melos prepare:compile:webworker && melos prepare:sqlite:wasm @@ -26,15 +32,16 @@ scripts: description: Analyze Dart code in packages. run: dart analyze packages --fatal-infos + # TODO: Temporarily setting the exit-code-threshold to 20 until drift_sqlite_async dependencies are updated. analyze:packages:pana: description: Analyze Dart packages with Pana - exec: dart pub global run pana --no-warning --exit-code-threshold 0 + exec: dart pub global run pana --no-warning --exit-code-threshold 20 packageFilters: noPrivate: true test: description: Run tests in a specific package. - run: dart test -p chrome,vm + run: dart test -p chrome,vm --compiler dart2js,dart2wasm exec: concurrency: 1 packageFilters: @@ -44,3 +51,11 @@ scripts: # as they could change the behaviour of how tests filter packages. env: MELOS_TEST: true + + test_build: + description: Runs tests with build_test + run: dart run build_runner test -- -p chrome + exec: + concurrency: 1 + packageFilters: + dependsOn: build_test diff --git a/packages/drift_sqlite_async/CHANGELOG.md b/packages/drift_sqlite_async/CHANGELOG.md index ce4d763..bd36dd1 100644 --- a/packages/drift_sqlite_async/CHANGELOG.md +++ b/packages/drift_sqlite_async/CHANGELOG.md @@ -1,3 +1,68 @@ +## 0.2.5 + + - Allow customizing update notifications from `sqlite_async`. + +## 0.2.4 + +- Allow transforming table updates from sqlite_async. + +## 0.2.3+1 + + - Update a dependency to the latest release. + +## 0.2.3 + +- Support nested transactions. + +## 0.2.2 + +- Fix write detection when using UPDATE/INSERT/DELETE with RETURNING in raw queries. + +## 0.2.1 + +- Fix lints. + +## 0.2.0 + + - Automatically run Drift migrations + +## 0.2.0-alpha.4 + + - Update a dependency to the latest release. + +## 0.2.0-alpha.3 + + - Bump `sqlite_async` to v0.10.1 + +## 0.2.0-alpha.2 + + - Bump `sqlite_async` to v0.10.0 + +## 0.2.0-alpha.1 + + - Support `drift` version >=2.19 and `web` v1.0.0. + - **BREAKING CHANGE**: Nested transactions through drift no longer create SAVEPOINTs. When nesting a drift `transaction`, the transaction is reused ([#65](https://github.com/powersync-ja/sqlite_async.dart/pull/65)). + +## 0.1.0-alpha.7 + + - Update a dependency to the latest release. + +## 0.1.0-alpha.6 + + - Update a dependency to the latest release. + +## 0.1.0-alpha.5 + + - Update a dependency to the latest release. + +## 0.1.0-alpha.4 + +- Import `sqlite3_common` instead of `sqlite3` for web support. + +## 0.1.0-alpha.3 + +- Update a dependency to the latest release. + ## 0.1.0-alpha.2 - Update dependency `sqlite_async` to version 0.8.0. diff --git a/packages/drift_sqlite_async/README.md b/packages/drift_sqlite_async/README.md index c732652..e8810b1 100644 --- a/packages/drift_sqlite_async/README.md +++ b/packages/drift_sqlite_async/README.md @@ -3,11 +3,12 @@ `drift_sqlite_async` allows using drift on an sqlite_async database - the APIs from both can be seamlessly used together in the same application. Supported functionality: + 1. All queries including select, insert, update, delete. 2. Transactions and nested transactions. 3. Table updates are propagated between sqlite_async and Drift - watching queries works using either API. 4. Select queries can run concurrently with writes and other select statements. - +5. Drift migrations are supported (optional). ## Usage @@ -59,4 +60,24 @@ These events are only sent while no write transaction is active. Within Drift's transactions, Drift's own update notifications will still apply for watching queries within that transaction. -Note: There is a possibility of events being duplicated. This should not have a significant impact on most applications. \ No newline at end of file +Note: There is a possibility of events being duplicated. This should not have a significant impact on most applications. + +## Web + +Note: Web support is currently in Beta. + +Web support requires Sqlite3 WASM and web worker Javascript files to be accessible. These file need to be put into the `web/` directory of your app. + +The compiled web worker files can be found in our Github [releases](https://github.com/powersync-ja/sqlite_async.dart/releases) +The `sqlite3.wasm` asset can be found [here](https://github.com/simolus3/sqlite3.dart/releases) + +In the end your `web/` directory will look like the following + +``` +web/ +├── favicon.png +├── index.html +├── manifest.json +├── db_worker.js +└── sqlite3.wasm +``` diff --git a/analysis_options.yaml b/packages/drift_sqlite_async/analysis_options.yaml similarity index 100% rename from analysis_options.yaml rename to packages/drift_sqlite_async/analysis_options.yaml diff --git a/packages/drift_sqlite_async/example/main.g.dart b/packages/drift_sqlite_async/example/main.g.dart index 576157c..0f4385e 100644 --- a/packages/drift_sqlite_async/example/main.g.dart +++ b/packages/drift_sqlite_async/example/main.g.dart @@ -109,6 +109,14 @@ class TodoItem extends DataClass implements Insertable { id: id ?? this.id, description: description ?? this.description, ); + TodoItem copyWithCompanion(TodoItemsCompanion data) { + return TodoItem( + id: data.id.present ? data.id.value : this.id, + description: + data.description.present ? data.description.value : this.description, + ); + } + @override String toString() { return (StringBuffer('TodoItem(') @@ -180,6 +188,7 @@ class TodoItemsCompanion extends UpdateCompanion { abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $TodoItemsTable todoItems = $TodoItemsTable(this); @override Iterable> get allTables => @@ -187,3 +196,125 @@ abstract class _$AppDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [todoItems]; } + +typedef $$TodoItemsTableCreateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + required String description, +}); +typedef $$TodoItemsTableUpdateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + Value description, +}); + +class $$TodoItemsTableFilterComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); +} + +class $$TodoItemsTableOrderingComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); +} + +class $$TodoItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); +} + +class $$TodoItemsTableTableManager extends RootTableManager< + _$AppDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$AppDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()> { + $$TodoItemsTableTableManager(_$AppDatabase db, $TodoItemsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TodoItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TodoItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TodoItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value description = const Value.absent(), + }) => + TodoItemsCompanion( + id: id, + description: description, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String description, + }) => + TodoItemsCompanion.insert( + id: id, + description: description, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$TodoItemsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$AppDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$TodoItemsTableTableManager get todoItems => + $$TodoItemsTableTableManager(_db, _db.todoItems); +} diff --git a/packages/drift_sqlite_async/example/with_migrations.dart b/packages/drift_sqlite_async/example/with_migrations.dart index 2bd4a87..7f8cb44 100644 --- a/packages/drift_sqlite_async/example/with_migrations.dart +++ b/packages/drift_sqlite_async/example/with_migrations.dart @@ -21,21 +21,18 @@ class AppDatabase extends _$AppDatabase { @override MigrationStrategy get migration { - return MigrationStrategy( - onCreate: (m) async { - // In this example, the schema is managed by Drift - await m.createAll(); - }, - ); + return MigrationStrategy(onCreate: (m) async { + // In this example, the schema is managed by Drift. + // For more options, see: + // https://drift.simonbinder.eu/migrations/#usage + await m.createAll(); + }); } } Future main() async { final db = SqliteDatabase(path: 'with_migrations.db'); - await db.execute( - 'CREATE TABLE IF NOT EXISTS todos(id integer primary key, description text)'); - final appdb = AppDatabase(db); // Watch a query on the Drift database diff --git a/packages/drift_sqlite_async/example/with_migrations.g.dart b/packages/drift_sqlite_async/example/with_migrations.g.dart index 67ce020..7c2afbd 100644 --- a/packages/drift_sqlite_async/example/with_migrations.g.dart +++ b/packages/drift_sqlite_async/example/with_migrations.g.dart @@ -109,6 +109,14 @@ class TodoItem extends DataClass implements Insertable { id: id ?? this.id, description: description ?? this.description, ); + TodoItem copyWithCompanion(TodoItemsCompanion data) { + return TodoItem( + id: data.id.present ? data.id.value : this.id, + description: + data.description.present ? data.description.value : this.description, + ); + } + @override String toString() { return (StringBuffer('TodoItem(') @@ -180,6 +188,7 @@ class TodoItemsCompanion extends UpdateCompanion { abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $TodoItemsTable todoItems = $TodoItemsTable(this); @override Iterable> get allTables => @@ -187,3 +196,125 @@ abstract class _$AppDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [todoItems]; } + +typedef $$TodoItemsTableCreateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + required String description, +}); +typedef $$TodoItemsTableUpdateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + Value description, +}); + +class $$TodoItemsTableFilterComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); +} + +class $$TodoItemsTableOrderingComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); +} + +class $$TodoItemsTableAnnotationComposer + extends Composer<_$AppDatabase, $TodoItemsTable> { + $$TodoItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); +} + +class $$TodoItemsTableTableManager extends RootTableManager< + _$AppDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$AppDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()> { + $$TodoItemsTableTableManager(_$AppDatabase db, $TodoItemsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TodoItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TodoItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TodoItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value description = const Value.absent(), + }) => + TodoItemsCompanion( + id: id, + description: description, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String description, + }) => + TodoItemsCompanion.insert( + id: id, + description: description, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$TodoItemsTableProcessedTableManager = ProcessedTableManager< + _$AppDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$AppDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()>; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$TodoItemsTableTableManager get todoItems => + $$TodoItemsTableTableManager(_db, _db.todoItems); +} diff --git a/packages/drift_sqlite_async/lib/drift_sqlite_async.dart b/packages/drift_sqlite_async/lib/drift_sqlite_async.dart index f842c5b..83d04fc 100644 --- a/packages/drift_sqlite_async/lib/drift_sqlite_async.dart +++ b/packages/drift_sqlite_async/lib/drift_sqlite_async.dart @@ -1,4 +1,2 @@ -library drift_sqlite_async; - export './src/connection.dart'; export './src/executor.dart'; diff --git a/packages/drift_sqlite_async/lib/src/connection.dart b/packages/drift_sqlite_async/lib/src/connection.dart index e375795..6c34ed4 100644 --- a/packages/drift_sqlite_async/lib/src/connection.dart +++ b/packages/drift_sqlite_async/lib/src/connection.dart @@ -15,12 +15,22 @@ import 'package:sqlite_async/sqlite_async.dart'; class SqliteAsyncDriftConnection extends DatabaseConnection { late StreamSubscription _updateSubscription; - SqliteAsyncDriftConnection(SqliteConnection db) - : super(SqliteAsyncQueryExecutor(db)) { + /// [transformTableUpdates] is useful to map local table names from PowerSync that are backed by a view name + /// which is the entity that the user interacts with. + SqliteAsyncDriftConnection( + SqliteConnection db, { + bool logStatements = false, + Set Function(UpdateNotification)? transformTableUpdates, + }) : super(SqliteAsyncQueryExecutor(db, logStatements: logStatements)) { _updateSubscription = (db as SqliteQueries).updates!.listen((event) { - var setUpdates = {}; - for (var tableName in event.tables) { - setUpdates.add(TableUpdate(tableName)); + final Set setUpdates; + if (transformTableUpdates != null) { + setUpdates = transformTableUpdates(event); + } else { + setUpdates = {}; + for (var tableName in event.tables) { + setUpdates.add(TableUpdate(tableName)); + } } super.streamQueries.handleTableUpdates(setUpdates); }); diff --git a/packages/drift_sqlite_async/lib/src/executor.dart b/packages/drift_sqlite_async/lib/src/executor.dart index a106b91..127462e 100644 --- a/packages/drift_sqlite_async/lib/src/executor.dart +++ b/packages/drift_sqlite_async/lib/src/executor.dart @@ -1,15 +1,27 @@ import 'dart:async'; import 'package:drift/backends.dart'; -import 'package:drift_sqlite_async/src/transaction_executor.dart'; -import 'package:sqlite_async/sqlite3.dart'; +import 'package:drift/drift.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; -class _SqliteAsyncDelegate extends DatabaseDelegate { +// Ends with " RETURNING *", or starts with insert/update/delete. +// Drift-generated queries will always have the RETURNING *. +// The INSERT/UPDATE/DELETE check is for custom queries, and is not exhaustive. +final _returningCheck = RegExp( + r'( RETURNING \*;?\s*$)|(^\s*(INSERT|UPDATE|DELETE))', + caseSensitive: false); + +class _SqliteAsyncDelegate extends _SqliteAsyncQueryDelegate + implements DatabaseDelegate { final SqliteConnection db; bool _closed = false; + bool _calledOpen = false; + + _SqliteAsyncDelegate(this.db) : super(db, db.writeLock); - _SqliteAsyncDelegate(this.db); + @override + bool isInTransaction = false; // unused @override late final DbVersionDelegate versionDelegate = @@ -18,22 +30,18 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { // Not used - we override beginTransaction() with SqliteAsyncTransactionExecutor for more control. @override late final TransactionDelegate transactionDelegate = - const NoTransactionDelegate(); + _SqliteAsyncTransactionDelegate(db); @override - bool get isOpen => !db.closed && !_closed; - - // Ends with " RETURNING *", or starts with insert/update/delete. - // Drift-generated queries will always have the RETURNING *. - // The INSERT/UPDATE/DELETE check is for custom queries, and is not exhaustive. - final _returningCheck = RegExp( - r'( RETURNING \*;?$)|(^(INSERT|UPDATE|DELETE))', - caseSensitive: false); + bool get isOpen => !db.closed && !_closed && _calledOpen; @override Future open(QueryExecutorUser user) async { // Workaround - this ensures the db is open await db.get('SELECT 1'); + // We need to delay this until open() has been called, otherwise + // migrations don't run. + _calledOpen = true; } @override @@ -42,9 +50,30 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { _closed = true; } + @override + void notifyDatabaseOpened(OpeningDetails details) { + // Unused + } +} + +class _SqliteAsyncQueryDelegate extends QueryDelegate { + final SqliteWriteContext _context; + final Future Function( + Future Function(SqliteWriteContext tx) callback)? _writeLock; + + _SqliteAsyncQueryDelegate(this._context, this._writeLock); + + Future writeLock(Future Function(SqliteWriteContext tx) callback) { + if (_writeLock case var writeLock?) { + return writeLock.call(callback); + } else { + return callback(_context); + } + } + @override Future runBatched(BatchedStatements statements) async { - return db.writeLock((tx) async { + return writeLock((tx) async { // sqlite_async's batch functionality doesn't have enough flexibility to support // this with prepared statements yet. for (final arg in statements.arguments) { @@ -56,12 +85,12 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { @override Future runCustom(String statement, List args) { - return db.execute(statement, args); + return _context.execute(statement, args); } @override Future runInsert(String statement, List args) async { - return db.writeLock((tx) async { + return writeLock((tx) async { await tx.execute(statement, args); final row = await tx.get('SELECT last_insert_rowid() as row_id'); return row['row_id']; @@ -77,17 +106,17 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { // This takes write lock, so we want to avoid it for plain select statements. // This is not an exhaustive check, but should cover all Drift-generated queries using // `runSelect()`. - result = await db.execute(statement, args); + result = await _context.execute(statement, args); } else { // Plain SELECT statement - use getAll() to avoid using a write lock. - result = await db.getAll(statement, args); + result = await _context.getAll(statement, args); } return QueryResult(result.columnNames, result.rows); } @override Future runUpdate(String statement, List args) { - return db.writeLock((tx) async { + return writeLock((tx) async { await tx.execute(statement, args); final row = await tx.get('SELECT changes() as changes'); return row['changes']; @@ -95,6 +124,35 @@ class _SqliteAsyncDelegate extends DatabaseDelegate { } } +class _SqliteAsyncTransactionDelegate extends SupportedTransactionDelegate { + final SqliteConnection _db; + + _SqliteAsyncTransactionDelegate(this._db); + + @override + FutureOr Function(QueryDelegate, Future Function(QueryDelegate))? + get startNested => _startNested; + + @override + Future startTransaction(Future Function(QueryDelegate p1) run) async { + await _startTransaction(_db, run); + } + + Future _startTransaction( + SqliteWriteContext context, Future Function(QueryDelegate p1) run) async { + await context.writeTransaction((context) async { + final delegate = _SqliteAsyncQueryDelegate(context, null); + return run(delegate); + }); + } + + Future _startNested( + QueryDelegate outer, Future Function(QueryDelegate) block) async { + await _startTransaction( + (outer as _SqliteAsyncQueryDelegate)._context, block); + } +} + class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { final SqliteConnection _db; @@ -127,10 +185,8 @@ class _SqliteAsyncVersionDelegate extends DynamicVersionDelegate { /// Extnral update notifications from the [SqliteConnection] are _not_ forwarded /// automatically - use [SqliteAsyncDriftConnection] for that. class SqliteAsyncQueryExecutor extends DelegatedDatabase { - SqliteAsyncQueryExecutor(SqliteConnection db) - : super( - _SqliteAsyncDelegate(db), - ); + SqliteAsyncQueryExecutor(SqliteConnection db, {bool logStatements = false}) + : super(_SqliteAsyncDelegate(db), logStatements: logStatements); /// The underlying SqliteConnection used by drift to send queries. SqliteConnection get db { @@ -139,9 +195,4 @@ class SqliteAsyncQueryExecutor extends DelegatedDatabase { @override bool get isSequential => false; - - @override - TransactionExecutor beginTransaction() { - return SqliteAsyncTransactionExecutor(db); - } } diff --git a/packages/drift_sqlite_async/lib/src/transaction_executor.dart b/packages/drift_sqlite_async/lib/src/transaction_executor.dart deleted file mode 100644 index ee4db9c..0000000 --- a/packages/drift_sqlite_async/lib/src/transaction_executor.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:async'; - -import 'package:drift/backends.dart'; -import 'package:sqlite_async/sqlite_async.dart'; - -/// Based on Drift's _WrappingTransactionExecutor, which is private. -/// Extended to support nested transactions. -/// -/// The outer SqliteAsyncTransactionExecutor uses sqlite_async's writeTransaction, which -/// does BEGIN/COMMIT/ROLLBACK. -/// -/// Nested transactions use SqliteAsyncNestedTransactionExecutor to implement SAVEPOINT / ROLLBACK. -class SqliteAsyncTransactionExecutor extends TransactionExecutor - with _TransactionQueryMixin { - final SqliteConnection _db; - static final _artificialRollback = - Exception('artificial exception to rollback the transaction'); - final Zone _createdIn = Zone.current; - final Completer _completerForCallback = Completer(); - Completer? _opened, _finished; - - /// Whether this executor has explicitly been closed. - bool _closed = false; - - @override - late SqliteWriteContext ctx; - - SqliteAsyncTransactionExecutor(this._db); - - void _checkCanOpen() { - if (_closed) { - throw StateError( - "A tranaction was used after being closed. Please check that you're " - 'awaiting all database operations inside a `transaction` block.'); - } - } - - @override - Future ensureOpen(QueryExecutorUser user) { - _checkCanOpen(); - var opened = _opened; - - if (opened == null) { - _opened = opened = Completer(); - _createdIn.run(() async { - final result = _db.writeTransaction((innerCtx) async { - opened!.complete(); - ctx = innerCtx; - await _completerForCallback.future; - }); - - _finished = Completer() - ..complete( - // ignore: void_checks - result - // Ignore the exception caused by [rollback] which may be - // rethrown by startTransaction - .onError((error, stackTrace) => null, - test: (e) => e == _artificialRollback) - // Consider this transaction closed after the call completes - // This may happen without send/rollback being called in - // case there's an exception when opening the transaction. - .whenComplete(() => _closed = true), - ); - }); - } - - // The opened completer is never completed if `startTransaction` throws - // before our callback is invoked (probably becaue `BEGIN` threw an - // exception). In that case, _finished will complete with that error though. - return Future.any([opened.future, if (_finished != null) _finished!.future]) - .then((value) => true); - } - - @override - Future send() async { - // don't do anything if the transaction completes before it was opened - if (_opened == null || _closed) return; - - _completerForCallback.complete(); - _closed = true; - await _finished?.future; - } - - @override - Future rollback() async { - // Note: This may be called after send() if send() throws (that is, the - // transaction can't be completed). But if completing fails, we assume that - // the transaction will implicitly be rolled back the underlying connection - // (it's not like we could explicitly roll it back, we only have one - // callback to implement). - if (_opened == null || _closed) return; - - _completerForCallback.completeError(_artificialRollback); - _closed = true; - await _finished?.future; - } - - @override - TransactionExecutor beginTransaction() { - return SqliteAsyncNestedTransactionExecutor(ctx, 1); - } - - @override - SqlDialect get dialect => SqlDialect.sqlite; - - @override - bool get supportsNestedTransactions => true; -} - -class SqliteAsyncNestedTransactionExecutor extends TransactionExecutor - with _TransactionQueryMixin { - @override - final SqliteWriteContext ctx; - - int depth; - - SqliteAsyncNestedTransactionExecutor(this.ctx, this.depth); - - @override - Future ensureOpen(QueryExecutorUser user) async { - await ctx.execute('SAVEPOINT tx$depth'); - return true; - } - - @override - Future send() async { - await ctx.execute('RELEASE SAVEPOINT tx$depth'); - } - - @override - Future rollback() async { - await ctx.execute('ROLLBACK TO SAVEPOINT tx$depth'); - } - - @override - TransactionExecutor beginTransaction() { - return SqliteAsyncNestedTransactionExecutor(ctx, depth + 1); - } - - @override - SqlDialect get dialect => SqlDialect.sqlite; - - @override - bool get supportsNestedTransactions => true; -} - -abstract class _QueryDelegate { - SqliteWriteContext get ctx; -} - -mixin _TransactionQueryMixin implements QueryExecutor, _QueryDelegate { - @override - Future runBatched(BatchedStatements statements) async { - // sqlite_async's batch functionality doesn't have enough flexibility to support - // this with prepared statements yet. - for (final arg in statements.arguments) { - await ctx.execute( - statements.statements[arg.statementIndex], arg.arguments); - } - } - - @override - Future runCustom(String statement, [List? args]) { - return ctx.execute(statement, args ?? const []); - } - - @override - Future runInsert(String statement, List args) async { - await ctx.execute(statement, args); - final row = await ctx.get('SELECT last_insert_rowid() as row_id'); - return row['row_id']; - } - - @override - Future>> runSelect( - String statement, List args) async { - final result = await ctx.execute(statement, args); - return QueryResult(result.columnNames, result.rows).asMap.toList(); - } - - @override - Future runUpdate(String statement, List args) async { - await ctx.execute(statement, args); - final row = await ctx.get('SELECT changes() as changes'); - return row['changes']; - } - - @override - Future runDelete(String statement, List args) { - return runUpdate(statement, args); - } -} diff --git a/packages/drift_sqlite_async/pubspec.yaml b/packages/drift_sqlite_async/pubspec.yaml index 42d998c..bd2b701 100644 --- a/packages/drift_sqlite_async/pubspec.yaml +++ b/packages/drift_sqlite_async/pubspec.yaml @@ -1,5 +1,5 @@ name: drift_sqlite_async -version: 0.1.0-alpha.2 +version: 0.2.5 homepage: https://github.com/powersync-ja/sqlite_async.dart repository: https://github.com/powersync-ja/sqlite_async.dart description: Use Drift with a sqlite_async database, allowing both to be used in the same application. @@ -14,12 +14,22 @@ topics: environment: sdk: ">=3.0.0 <4.0.0" dependencies: - drift: ^2.15.0 - sqlite_async: ^0.8.0 + drift: ">=2.28.0 <3.0.0" + sqlite_async: ^0.12.2 + dev_dependencies: build_runner: ^2.4.8 - drift_dev: ^2.15.0 + drift_dev: ">=2.28.0 <3.0.0" glob: ^2.1.2 + lints: ^5.0.0 sqlite3: ^2.4.0 test: ^1.25.2 test_api: ^0.7.0 + +platforms: + android: + ios: + linux: + macos: + windows: + web: diff --git a/packages/drift_sqlite_async/test/basic_test.dart b/packages/drift_sqlite_async/test/basic_test.dart index 503604f..cac6f55 100644 --- a/packages/drift_sqlite_async/test/basic_test.dart +++ b/packages/drift_sqlite_async/test/basic_test.dart @@ -1,13 +1,17 @@ // TODO @TestOn('!browser') +library; + import 'dart:async'; import 'package:drift/drift.dart'; import 'package:drift_sqlite_async/drift_sqlite_async.dart'; +import 'package:sqlite3/common.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; import './utils/test_utils.dart'; +import 'generated/database.dart'; class EmptyDatabase extends GeneratedDatabase { EmptyDatabase(super.executor); @@ -129,6 +133,13 @@ void main() { // This runs outside the transaction expect(await db.get('select count(*) as count from test_data'), equals({'count': 0})); + + // This runs in the transaction + final countInTransaction = (await dbu + .customSelect('select count(*) as count from test_data') + .getSingle()) + .data; + expect(countInTransaction, equals({'count': 2})); }); expect(await db.get('select count(*) as count from test_data'), @@ -210,5 +221,68 @@ void main() { .data, equals({'count': 1})); }); + + test('cannot update database with read', () async { + await expectLater(() => dbu.customSelect(''' +-- trick to circumvent regex detecting writes +INSERT INTO test_data(description) VALUES('test data'); +''').get(), throwsA(isA())); + }); + + test('allows spaces after returning', () async { + // This tests that the statement is forwarded to the write connection + // despite using customSelect(). If it wasn't, we'd get an error about + // the database being read-only. + final row = await dbu.customSelect( + 'INSERT INTO test_data(description) VALUES(?) RETURNING * ', + variables: [Variable('Test Data')]).getSingle(); + expect(row.data['description'], equals('Test Data')); + }); + + test('allows spaces before insert', () async { + final row = await dbu.customSelect( + ' INSERT INTO test_data(description) VALUES(?) ', + variables: [Variable('Test Data')]).get(); + expect(row, isEmpty); + }); + }); + + test('transform table updates', () async { + final path = dbPath(); + await cleanDb(path: path); + + final db = await setupDatabase(path: path); + final connection = SqliteAsyncDriftConnection( + db, + // tables with the local_ prefix are mapped to the name without the prefix + transformTableUpdates: (event) { + final updates = {}; + + for (final originalTableName in event.tables) { + final effectiveName = originalTableName.startsWith("local_") + ? originalTableName.substring(6) + : originalTableName; + updates.add(TableUpdate(effectiveName)); + } + + return updates; + }, + ); + + // Create table with a different name than drift. (Mimicking a table name backed by a view in PowerSync with the optional sync strategy) + await db.execute( + 'CREATE TABLE local_todos(id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT)', + ); + + final dbu = TodoDatabase.fromSqliteAsyncConnection(connection); + + final tableUpdatesFut = + dbu.tableUpdates(TableUpdateQuery.onTableName("todos")).first; + + // This insert will trigger the sqlite_async "updates" stream + await db.execute("INSERT INTO local_todos(description) VALUES('Test 1')"); + + expect(await tableUpdatesFut.timeout(const Duration(seconds: 2)), + {TableUpdate("todos")}); }); } diff --git a/packages/drift_sqlite_async/test/db_test.dart b/packages/drift_sqlite_async/test/db_test.dart index ed901cf..f5b269a 100644 --- a/packages/drift_sqlite_async/test/db_test.dart +++ b/packages/drift_sqlite_async/test/db_test.dart @@ -1,5 +1,7 @@ // TODO @TestOn('!browser') +library; + import 'package:drift/drift.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; @@ -48,8 +50,15 @@ void main() { test('watch', () async { var stream = dbu.select(dbu.todoItems).watch(); - var resultsPromise = - stream.distinct().skipWhile((e) => e.isEmpty).take(3).toList(); + var resultsPromise = stream + // toString() so that we can use distinct() + .map((rows) => rows.toString()) + // Drift may or may not emit duplicate update notifications. + // We use distinct() to ignore those. + .distinct() + .skipWhile((e) => e == '[]') + .take(3) + .toList(); await dbu.into(dbu.todoItems).insert( TodoItemsCompanion.insert(id: Value(1), description: 'Test 1')); @@ -65,16 +74,20 @@ void main() { expect( results, equals([ - [TodoItem(id: 1, description: 'Test 1')], - [TodoItem(id: 1, description: 'Test 1B')], - [] + '[TodoItem(id: 1, description: Test 1)]', + '[TodoItem(id: 1, description: Test 1B)]', + '[]' ])); }); test('watch with external updates', () async { var stream = dbu.select(dbu.todoItems).watch(); - var resultsPromise = - stream.distinct().skipWhile((e) => e.isEmpty).take(3).toList(); + var resultsPromise = stream + .map((rows) => rows.toString()) + .distinct() + .skipWhile((e) => e == '[]') + .take(3) + .toList(); await db.execute( 'INSERT INTO todos(id, description) VALUES(?, ?)', [1, 'Test 1']); @@ -88,10 +101,51 @@ void main() { expect( results, equals([ - [TodoItem(id: 1, description: 'Test 1')], - [TodoItem(id: 1, description: 'Test 1B')], - [] + '[TodoItem(id: 1, description: Test 1)]', + '[TodoItem(id: 1, description: Test 1B)]', + '[]' ])); }); + + test('delete returns affected rows', () async { + for (var i = 0; i < 10; i++) { + await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'desc $i')); + } + + final deleted = await dbu.delete(dbu.todoItems).go(); + expect(deleted, 10); + }); + + test('nested transactions', () async { + await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'root')); + + await dbu.transaction(() async { + await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'tx0')); + + await dbu.transaction(() async { + await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'tx1')); + + await expectLater(() { + return dbu.transaction(() async { + await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'tx2')); + throw 'rollback'; + }); + }, throwsA(anything)); + }); + }); + + final items = await dbu.todoItems.all().get(); + expect(items.map((e) => e.description).toSet(), {'root', 'tx0', 'tx1'}); + }); }); } diff --git a/packages/drift_sqlite_async/test/generated/database.dart b/packages/drift_sqlite_async/test/generated/database.dart index e955c3d..4e03600 100644 --- a/packages/drift_sqlite_async/test/generated/database.dart +++ b/packages/drift_sqlite_async/test/generated/database.dart @@ -16,6 +16,21 @@ class TodoItems extends Table { class TodoDatabase extends _$TodoDatabase { TodoDatabase(SqliteConnection db) : super(SqliteAsyncDriftConnection(db)); + TodoDatabase.fromSqliteAsyncConnection(SqliteAsyncDriftConnection super.conn); + @override int get schemaVersion => 1; } + +class TodosMigrationDatabase extends TodoDatabase { + TodosMigrationDatabase(super.db); + + @override + MigrationStrategy get migration { + return MigrationStrategy( + onCreate: (m) async { + await m.createAll(); + }, + ); + } +} diff --git a/packages/drift_sqlite_async/test/generated/database.g.dart b/packages/drift_sqlite_async/test/generated/database.g.dart index 2572c32..fecba2b 100644 --- a/packages/drift_sqlite_async/test/generated/database.g.dart +++ b/packages/drift_sqlite_async/test/generated/database.g.dart @@ -109,6 +109,14 @@ class TodoItem extends DataClass implements Insertable { id: id ?? this.id, description: description ?? this.description, ); + TodoItem copyWithCompanion(TodoItemsCompanion data) { + return TodoItem( + id: data.id.present ? data.id.value : this.id, + description: + data.description.present ? data.description.value : this.description, + ); + } + @override String toString() { return (StringBuffer('TodoItem(') @@ -180,6 +188,7 @@ class TodoItemsCompanion extends UpdateCompanion { abstract class _$TodoDatabase extends GeneratedDatabase { _$TodoDatabase(QueryExecutor e) : super(e); + $TodoDatabaseManager get managers => $TodoDatabaseManager(this); late final $TodoItemsTable todoItems = $TodoItemsTable(this); @override Iterable> get allTables => @@ -187,3 +196,125 @@ abstract class _$TodoDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [todoItems]; } + +typedef $$TodoItemsTableCreateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + required String description, +}); +typedef $$TodoItemsTableUpdateCompanionBuilder = TodoItemsCompanion Function({ + Value id, + Value description, +}); + +class $$TodoItemsTableFilterComposer + extends Composer<_$TodoDatabase, $TodoItemsTable> { + $$TodoItemsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnFilters(column)); +} + +class $$TodoItemsTableOrderingComposer + extends Composer<_$TodoDatabase, $TodoItemsTable> { + $$TodoItemsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, builder: (column) => ColumnOrderings(column)); +} + +class $$TodoItemsTableAnnotationComposer + extends Composer<_$TodoDatabase, $TodoItemsTable> { + $$TodoItemsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, builder: (column) => column); +} + +class $$TodoItemsTableTableManager extends RootTableManager< + _$TodoDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$TodoDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()> { + $$TodoItemsTableTableManager(_$TodoDatabase db, $TodoItemsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$TodoItemsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$TodoItemsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$TodoItemsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value description = const Value.absent(), + }) => + TodoItemsCompanion( + id: id, + description: description, + ), + createCompanionCallback: ({ + Value id = const Value.absent(), + required String description, + }) => + TodoItemsCompanion.insert( + id: id, + description: description, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$TodoItemsTableProcessedTableManager = ProcessedTableManager< + _$TodoDatabase, + $TodoItemsTable, + TodoItem, + $$TodoItemsTableFilterComposer, + $$TodoItemsTableOrderingComposer, + $$TodoItemsTableAnnotationComposer, + $$TodoItemsTableCreateCompanionBuilder, + $$TodoItemsTableUpdateCompanionBuilder, + (TodoItem, BaseReferences<_$TodoDatabase, $TodoItemsTable, TodoItem>), + TodoItem, + PrefetchHooks Function()>; + +class $TodoDatabaseManager { + final _$TodoDatabase _db; + $TodoDatabaseManager(this._db); + $$TodoItemsTableTableManager get todoItems => + $$TodoItemsTableTableManager(_db, _db.todoItems); +} diff --git a/packages/drift_sqlite_async/test/migration_test.dart b/packages/drift_sqlite_async/test/migration_test.dart new file mode 100644 index 0000000..ca9cb3f --- /dev/null +++ b/packages/drift_sqlite_async/test/migration_test.dart @@ -0,0 +1,42 @@ +@TestOn('!browser') +library; + +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:test/test.dart'; + +import './utils/test_utils.dart'; +import 'generated/database.dart'; + +void main() { + group('Migration tests', () { + late String path; + late SqliteDatabase db; + late TodoDatabase dbu; + + setUp(() async { + path = dbPath(); + await cleanDb(path: path); + + db = await setupDatabase(path: path); + dbu = TodosMigrationDatabase(db); + }); + + tearDown(() async { + await dbu.close(); + await db.close(); + + await cleanDb(path: path); + }); + + test('INSERT/SELECT', () async { + // This will fail if the migration didn't run + var insertRowId = await dbu + .into(dbu.todoItems) + .insert(TodoItemsCompanion.insert(description: 'Test 1')); + expect(insertRowId, greaterThanOrEqualTo(1)); + + final result = await dbu.select(dbu.todoItems).getSingle(); + expect(result.description, equals('Test 1')); + }); + }); +} diff --git a/packages/sqlite_async/CHANGELOG.md b/packages/sqlite_async/CHANGELOG.md index e38d5cb..1bbc939 100644 --- a/packages/sqlite_async/CHANGELOG.md +++ b/packages/sqlite_async/CHANGELOG.md @@ -1,3 +1,97 @@ +## 0.12.2 + + - Add `withAllConnections` method to run statements on all connections in the pool. + +## 0.12.1 + +- Fix distributing updates from shared worker. + +## 0.12.0 + + - Avoid large transactions creating a large internal update queue. + +## 0.11.8 + +- Support nested transactions (emulated with `SAVEPOINT` statements). +- Fix web compilation issues with version `2.8.0` of `package:sqlite3`. + +## 0.11.7 + +- Shared worker: Release locks owned by connected client tab when it closes. +- Fix web concurrency issues: Consistently apply a shared mutex or let a shared + worker coordinate access. + +## 0.11.6 + +- Native: Consistently report errors when opening the database instead of + causing unhandled exceptions. + +## 0.11.5 + +- Allow profiling queries. Queries are profiled by default in debug and profile builds, the runtime + for queries is added to profiling timelines under the `sqlite_async` tag. +- Fix cancelling `watch()` queries sometimes taking longer than necessary. +- Fix web databases not respecting lock timeouts. + +## 0.11.4 + +- Add `SqliteConnection.synchronousWrapper` and `SqliteDatabase.singleConnection`. + Together, these can be used to wrap raw `CommonDatabase` instances from `package:sqlite3` + as a `Database` (without an automated worker or isolate setup). This can be useful in tests + where synchronous access to the underlying database is convenient. + +## 0.11.3 + +- Support being compiled with `package:build_web_compilers`. + +## 0.11.2 + +- Support latest version of `package:sqlite3_web`. +- Support `dart2wasm`. + +## 0.11.1 + +- Remove remaining `dart:js_util` imports in favor of new interop APIs. +- Add `WebSqliteOpenFactory` with web-specific behavior for open factories. + +## 0.11.0 + + - Automatically flush IndexedDB storage to fix durability issues + +## 0.10.1 + +- For database setups not using a shared worker, use a `BroadcastChannel` to share updates across different tabs. + +## 0.10.0 + +- Add the `exposeEndpoint()` method available on web databases. It returns a serializable + description of the database endpoint that can be sent across workers. + This allows sharing an opened database connection across workers. + +## 0.9.1 + +- Support version ^0.2.0 of package:sqlite3_web +- Fix update notifications to only fire outside transactions +- Fix update notifications to be debounced on web + +## 0.9.0 + +- Support the latest version of package:web and package:sqlite3_web + +- Export sqlite3 `open` for packages that depend on `sqlite_async` + +## 0.8.3 + +- Updated web database implementation for get and getOptional. Fixed refreshSchema not working in web. + +## 0.8.2 + +- **FEAT**: Added `refreshSchema()`, allowing queries and watch calls to work against updated schemas. + +## 0.8.1 + +- Added Navigator locks for web `Mutex`s. + ## 0.8.0 - Added web support (web functionality is in beta) diff --git a/packages/sqlite_async/analysis_options.yaml b/packages/sqlite_async/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/packages/sqlite_async/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/sqlite_async/build.yaml b/packages/sqlite_async/build.yaml new file mode 100644 index 0000000..0774cc7 --- /dev/null +++ b/packages/sqlite_async/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + build_web_compilers:entrypoint: + options: + # Workers can't be compiled with dartdevc, so use dart2js for the example + compiler: dart2js diff --git a/packages/sqlite_async/example/web/index.html b/packages/sqlite_async/example/web/index.html new file mode 100644 index 0000000..4bc69de --- /dev/null +++ b/packages/sqlite_async/example/web/index.html @@ -0,0 +1,29 @@ + + + + + + sqlite_async web demo + + + + + +

sqlite_async demo

+ +
+This page is used to test the sqlite_async package on the web. +Use the console to open and interact with databases. + +
+
+const db = await open('test.db');
+const lock = await write_lock(db);
+release_lock(lock);
+
+
+
+ + + + diff --git a/packages/sqlite_async/example/web/main.dart b/packages/sqlite_async/example/web/main.dart new file mode 100644 index 0000000..95bd1f1 --- /dev/null +++ b/packages/sqlite_async/example/web/main.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:sqlite_async/sqlite_async.dart'; + +void main() { + globalContext['open'] = (String path) { + return Future(() async { + final db = SqliteDatabase( + path: path, + options: SqliteOptions( + webSqliteOptions: WebSqliteOptions( + wasmUri: + 'https://cdn.jsdelivr.net/npm/@powersync/dart-wasm-bundles@latest/dist/sqlite3.wasm', + workerUri: 'worker.dart.js', + ), + ), + ); + await db.initialize(); + return db.toJSBox; + }).toJS; + }.toJS; + + globalContext['write_lock'] = (JSBoxedDartObject db) { + final hasLock = Completer(); + final completer = Completer(); + + (db.toDart as SqliteDatabase).writeLock((_) async { + print('has write lock!'); + hasLock.complete(); + await completer.future; + }); + + return hasLock.future.then((_) => completer.toJSBox).toJS; + }.toJS; + + globalContext['release_lock'] = (JSBoxedDartObject db) { + (db.toDart as Completer).complete(); + }.toJS; +} diff --git a/packages/sqlite_async/example/web/worker.dart b/packages/sqlite_async/example/web/worker.dart new file mode 100644 index 0000000..481455d --- /dev/null +++ b/packages/sqlite_async/example/web/worker.dart @@ -0,0 +1,6 @@ +import 'package:sqlite_async/sqlite3_web.dart'; +import 'package:sqlite_async/sqlite3_web_worker.dart'; + +void main() { + WebSqlite.workerEntrypoint(controller: AsyncSqliteController()); +} diff --git a/packages/sqlite_async/lib/sqlite3_open.dart b/packages/sqlite_async/lib/sqlite3_open.dart new file mode 100644 index 0000000..9d9b0a2 --- /dev/null +++ b/packages/sqlite_async/lib/sqlite3_open.dart @@ -0,0 +1,3 @@ +// Re-exports [sqlite3 open](https://pub.dev/packages/sqlite3) to expose sqlite3 open without +// adding it as a direct dependency. +export 'package:sqlite3/open.dart'; diff --git a/packages/sqlite_async/lib/src/common/connection/sync_sqlite_connection.dart b/packages/sqlite_async/lib/src/common/connection/sync_sqlite_connection.dart index f29520f..f63e0df 100644 --- a/packages/sqlite_async/lib/src/common/connection/sync_sqlite_connection.dart +++ b/packages/sqlite_async/lib/src/common/connection/sync_sqlite_connection.dart @@ -1,12 +1,18 @@ +import 'dart:developer'; + import 'package:sqlite3/common.dart'; import 'package:sqlite_async/src/common/mutex.dart'; import 'package:sqlite_async/src/sqlite_connection.dart'; +import 'package:sqlite_async/src/sqlite_options.dart'; import 'package:sqlite_async/src/sqlite_queries.dart'; import 'package:sqlite_async/src/update_notification.dart'; +import 'package:sqlite_async/src/utils/profiler.dart'; + +import '../../impl/context.dart'; /// A simple "synchronous" connection which provides the async SqliteConnection /// implementation using a synchronous SQLite connection -class SyncSqliteConnection extends SqliteConnection with SqliteQueries { +class SyncSqliteConnection with SqliteQueries implements SqliteConnection { final CommonDatabase db; late Mutex mutex; @override @@ -14,7 +20,15 @@ class SyncSqliteConnection extends SqliteConnection with SqliteQueries { bool _closed = false; - SyncSqliteConnection(this.db, Mutex m) { + /// Whether queries should be added to the `dart:developer` timeline. + /// + /// This is enabled by default outside of release builds, see + /// [SqliteOptions.profileQueries] for details. + final bool profileQueries; + + SyncSqliteConnection(this.db, Mutex m, {bool? profileQueries}) + : profileQueries = + profileQueries ?? const SqliteOptions().profileQueries { mutex = m.open(); updates = db.updates.map( (event) { @@ -26,15 +40,37 @@ class SyncSqliteConnection extends SqliteConnection with SqliteQueries { @override Future readLock(Future Function(SqliteReadContext tx) callback, {Duration? lockTimeout, String? debugContext}) { - return mutex.lock(() => callback(SyncReadContext(db)), - timeout: lockTimeout); + final task = profileQueries ? TimelineTask() : null; + task?.start('${profilerPrefix}mutex_lock'); + + return mutex.lock( + () { + task?.finish(); + return ScopedReadContext.assumeReadLock( + _UnsafeSyncContext(db, parent: task), + callback, + ); + }, + timeout: lockTimeout, + ); } @override Future writeLock(Future Function(SqliteWriteContext tx) callback, {Duration? lockTimeout, String? debugContext}) { - return mutex.lock(() => callback(SyncWriteContext(db)), - timeout: lockTimeout); + final task = profileQueries ? TimelineTask() : null; + task?.start('${profilerPrefix}mutex_lock'); + + return mutex.lock( + () { + task?.finish(); + return ScopedWriteContext.assumeWriteLock( + _UnsafeSyncContext(db, parent: task), + callback, + ); + }, + timeout: lockTimeout, + ); } @override @@ -52,10 +88,13 @@ class SyncSqliteConnection extends SqliteConnection with SqliteQueries { } } -class SyncReadContext implements SqliteReadContext { +final class _UnsafeSyncContext extends UnscopedContext { + final TimelineTask? task; + CommonDatabase db; - SyncReadContext(this.db); + _UnsafeSyncContext(this.db, {TimelineTask? parent}) + : task = TimelineTask(parent: parent); @override Future computeWithDatabase( @@ -65,13 +104,23 @@ class SyncReadContext implements SqliteReadContext { @override Future get(String sql, [List parameters = const []]) async { - return db.select(sql, parameters).first; + return task.timeSync( + 'get', + () => db.select(sql, parameters).first, + sql: sql, + parameters: parameters, + ); } @override Future getAll(String sql, [List parameters = const []]) async { - return db.select(sql, parameters); + return task.timeSync( + 'getAll', + () => db.select(sql, parameters), + sql: sql, + parameters: parameters, + ); } @override @@ -88,29 +137,31 @@ class SyncReadContext implements SqliteReadContext { Future getAutoCommit() async { return db.autocommit; } -} - -class SyncWriteContext extends SyncReadContext implements SqliteWriteContext { - SyncWriteContext(super.db); @override Future execute(String sql, [List parameters = const []]) async { - return db.select(sql, parameters); + return task.timeSync( + 'execute', + () => db.select(sql, parameters), + sql: sql, + parameters: parameters, + ); } @override Future executeBatch( String sql, List> parameterSets) async { - return computeWithDatabase((db) async { + task.timeSync('executeBatch', () { final statement = db.prepare(sql, checkNoTail: true); try { for (var parameters in parameterSets) { - statement.execute(parameters); + task.timeSync('iteration', () => statement.execute(parameters), + parameters: parameters); } } finally { statement.dispose(); } - }); + }, sql: sql); } } diff --git a/packages/sqlite_async/lib/src/common/mutex.dart b/packages/sqlite_async/lib/src/common/mutex.dart index ccdcc49..edcdd49 100644 --- a/packages/sqlite_async/lib/src/common/mutex.dart +++ b/packages/sqlite_async/lib/src/common/mutex.dart @@ -1,8 +1,14 @@ import 'package:sqlite_async/src/impl/mutex_impl.dart'; abstract class Mutex { - factory Mutex() { - return MutexImpl(); + factory Mutex( + { + /// An optional identifier for this Mutex instance. + /// This could be used for platform specific logic or debugging purposes. + /// Currently this is not used on native platforms. + /// On web this will be used for the lock name if Navigator locks are available. + String? identifier}) { + return MutexImpl(identifier: identifier); } /// timeout is a timeout for acquiring the lock, not for the callback diff --git a/packages/sqlite_async/lib/src/common/port_channel.dart b/packages/sqlite_async/lib/src/common/port_channel.dart index 8b05feb..fc5e69c 100644 --- a/packages/sqlite_async/lib/src/common/port_channel.dart +++ b/packages/sqlite_async/lib/src/common/port_channel.dart @@ -1,352 +1,2 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:isolate'; - -abstract class PortClient { - Future post(Object message); - void fire(Object message); - - factory PortClient.parent() { - return ParentPortClient(); - } - - factory PortClient.child(SendPort upstream) { - return ChildPortClient(upstream); - } -} - -class ParentPortClient implements PortClient { - late Future sendPortFuture; - SendPort? sendPort; - final ReceivePort _receivePort = ReceivePort(); - final ReceivePort _errorPort = ReceivePort(); - bool closed = false; - Object? _closeError; - String? _isolateDebugName; - int _nextId = 1; - - Map> handlers = HashMap(); - - ParentPortClient() { - final initCompleter = Completer.sync(); - sendPortFuture = initCompleter.future; - sendPortFuture.then((value) { - sendPort = value; - }); - _receivePort.listen((message) { - if (message is _InitMessage) { - assert(!initCompleter.isCompleted); - initCompleter.complete(message.port); - } else if (message is _PortChannelResult) { - final handler = handlers.remove(message.requestId); - assert(handler != null); - if (message.success) { - handler!.complete(message.result); - } else { - handler!.completeError(message.error, message.stackTrace); - } - } else if (message == _closeMessage) { - close(); - } - }, onError: (e) { - if (!initCompleter.isCompleted) { - initCompleter.completeError(e); - } - - close(); - }, onDone: () { - if (!initCompleter.isCompleted) { - initCompleter.completeError(ClosedException()); - } - close(); - }); - _errorPort.listen((message) { - final [error, stackTraceString] = message; - final stackTrace = stackTraceString == null - ? null - : StackTrace.fromString(stackTraceString); - if (!initCompleter.isCompleted) { - initCompleter.completeError(error, stackTrace); - } - _close(IsolateError(cause: error, isolateDebugName: _isolateDebugName), - stackTrace); - }); - } - - Future get ready async { - await sendPortFuture; - } - - void _cancelAll(Object error, [StackTrace? stackTrace]) { - var handlers = this.handlers; - this.handlers = {}; - for (var message in handlers.values) { - message.completeError(error, stackTrace); - } - } - - @override - Future post(Object message) async { - if (closed) { - throw _closeError ?? const ClosedException(); - } - var completer = Completer.sync(); - var id = _nextId++; - handlers[id] = completer; - final port = sendPort ?? await sendPortFuture; - port.send(_RequestMessage(id, message, null)); - return await completer.future; - } - - @override - void fire(Object message) async { - if (closed) { - throw _closeError ?? ClosedException(); - } - final port = sendPort ?? await sendPortFuture; - port.send(_FireMessage(message)); - } - - RequestPortServer server() { - return RequestPortServer(_receivePort.sendPort); - } - - void _close([Object? error, StackTrace? stackTrace]) { - if (!closed) { - closed = true; - - _receivePort.close(); - _errorPort.close(); - if (error == null) { - _cancelAll(const ClosedException()); - } else { - _closeError = error; - _cancelAll(error, stackTrace); - } - } - } - - void close() { - _close(); - } - - tieToIsolate(Isolate isolate) { - _isolateDebugName = isolate.debugName; - isolate.addErrorListener(_errorPort.sendPort); - isolate.addOnExitListener(_receivePort.sendPort, response: _closeMessage); - } -} - -class SerializedPortClient { - final SendPort sendPort; - - SerializedPortClient(this.sendPort); - - ChildPortClient open() { - return ChildPortClient(sendPort); - } -} - -class ChildPortClient implements PortClient { - final SendPort sendPort; - final ReceivePort receivePort = ReceivePort(); - int _nextId = 1; - bool closed = false; - - final Map> handlers = HashMap(); - - ChildPortClient(this.sendPort) { - receivePort.listen((message) { - if (message is _PortChannelResult) { - final handler = handlers.remove(message.requestId); - assert(handler != null); - if (message.success) { - handler!.complete(message.result); - } else { - handler!.completeError(message.error, message.stackTrace); - } - } - }); - } - - @override - Future post(Object message) async { - if (closed) { - throw const ClosedException(); - } - var completer = Completer.sync(); - var id = _nextId++; - handlers[id] = completer; - sendPort.send(_RequestMessage(id, message, receivePort.sendPort)); - return await completer.future; - } - - @override - void fire(Object message) { - if (closed) { - throw ClosedException(); - } - sendPort.send(_FireMessage(message)); - } - - void _cancelAll(Object error) { - var handlers = HashMap>.from(this.handlers); - this.handlers.clear(); - for (var message in handlers.values) { - message.completeError(error); - } - } - - void close() { - closed = true; - _cancelAll(const ClosedException()); - receivePort.close(); - } -} - -class RequestPortServer { - final SendPort port; - - RequestPortServer(this.port); - - open(Future Function(Object? message) handle) { - return PortServer.forSendPort(port, handle); - } -} - -class PortServer { - final ReceivePort _receivePort = ReceivePort(); - final Future Function(Object? message) handle; - final SendPort? replyPort; - - PortServer(this.handle) : replyPort = null { - _init(); - } - - PortServer.forSendPort(SendPort port, this.handle) : replyPort = port { - port.send(_InitMessage(_receivePort.sendPort)); - _init(); - } - - SendPort get sendPort { - return _receivePort.sendPort; - } - - SerializedPortClient client() { - return SerializedPortClient(sendPort); - } - - void close() { - _receivePort.close(); - } - - void _init() { - _receivePort.listen((request) async { - if (request is _FireMessage) { - handle(request.message); - } else if (request is _RequestMessage) { - if (request.id == 0) { - // Fire and forget - handle(request.message); - } else { - final replyPort = request.reply ?? this.replyPort; - try { - var result = await handle(request.message); - replyPort!.send(_PortChannelResult.success(request.id, result)); - } catch (e, stacktrace) { - replyPort! - .send(_PortChannelResult.error(request.id, e, stacktrace)); - } - } - } - }); - } -} - -const _closeMessage = '_Close'; - -class _InitMessage { - final SendPort port; - - _InitMessage(this.port); -} - -class _FireMessage { - final Object message; - - const _FireMessage(this.message); -} - -class _RequestMessage { - final int id; - final Object message; - final SendPort? reply; - - _RequestMessage(this.id, this.message, this.reply); -} - -class ClosedException implements Exception { - const ClosedException(); - - @override - String toString() { - return 'ClosedException'; - } -} - -class IsolateError extends Error { - final Object cause; - final String? isolateDebugName; - - IsolateError({required this.cause, this.isolateDebugName}); - - @override - String toString() { - if (isolateDebugName != null) { - return 'IsolateError in $isolateDebugName: $cause'; - } else { - return 'IsolateError: $cause'; - } - } -} - -class _PortChannelResult { - final int requestId; - final bool success; - final T? _result; - final Object? _error; - final StackTrace? stackTrace; - - const _PortChannelResult.success(this.requestId, T result) - : success = true, - _error = null, - stackTrace = null, - _result = result; - const _PortChannelResult.error(this.requestId, Object error, - [this.stackTrace]) - : success = false, - _result = null, - _error = error; - - T get value { - if (success) { - return _result as T; - } else { - if (_error != null && stackTrace != null) { - Error.throwWithStackTrace(_error, stackTrace!); - } else { - throw _error!; - } - } - } - - T get result { - assert(success); - return _result as T; - } - - Object get error { - assert(!success); - return _error!; - } -} +export 'port_channel_native.dart' + if (dart.library.js_interop) 'port_channel_stub.dart'; diff --git a/packages/sqlite_async/lib/src/common/port_channel_native.dart b/packages/sqlite_async/lib/src/common/port_channel_native.dart new file mode 100644 index 0000000..bb8318e --- /dev/null +++ b/packages/sqlite_async/lib/src/common/port_channel_native.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:isolate'; + +abstract class PortClient { + Future post(Object message); + void fire(Object message); + + factory PortClient.parent() { + return ParentPortClient(); + } + + factory PortClient.child(SendPort upstream) { + return ChildPortClient(upstream); + } +} + +class ParentPortClient implements PortClient { + late Future sendPortFuture; + SendPort? sendPort; + final ReceivePort _receivePort = ReceivePort(); + final ReceivePort _errorPort = ReceivePort(); + bool closed = false; + Object? _closeError; + String? _isolateDebugName; + int _nextId = 1; + + Map> handlers = HashMap(); + + ParentPortClient() { + final initCompleter = Completer.sync(); + sendPortFuture = initCompleter.future; + _receivePort.listen((message) { + if (message is _InitMessage) { + assert(!initCompleter.isCompleted); + sendPort = message.port; + initCompleter.complete(message.port); + } else if (message is _PortChannelResult) { + final handler = handlers.remove(message.requestId); + assert(handler != null); + if (message.success) { + handler!.complete(message.result); + } else { + handler!.completeError(message.error, message.stackTrace); + } + } else if (message == _closeMessage) { + close(); + } + }, onError: (e) { + if (!initCompleter.isCompleted) { + initCompleter.completeError(e); + } + + close(); + }, onDone: () { + if (!initCompleter.isCompleted) { + initCompleter.completeError(ClosedException()); + } + close(); + }); + _errorPort.listen((message) { + final [error, stackTraceString] = message; + final stackTrace = stackTraceString == null + ? null + : StackTrace.fromString(stackTraceString); + if (!initCompleter.isCompleted) { + initCompleter.completeError(error, stackTrace); + } + _close(IsolateError(cause: error, isolateDebugName: _isolateDebugName), + stackTrace); + }); + } + + Future get ready async { + await sendPortFuture; + } + + void _cancelAll(Object error, [StackTrace? stackTrace]) { + var handlers = this.handlers; + this.handlers = {}; + for (var message in handlers.values) { + message.completeError(error, stackTrace); + } + } + + @override + Future post(Object message) async { + if (closed) { + throw _closeError ?? const ClosedException(); + } + var completer = Completer.sync(); + var id = _nextId++; + handlers[id] = completer; + final port = sendPort ?? await sendPortFuture; + port.send(_RequestMessage(id, message, null)); + return await completer.future; + } + + @override + void fire(Object message) async { + if (closed) { + throw _closeError ?? ClosedException(); + } + final port = sendPort ?? await sendPortFuture; + port.send(_FireMessage(message)); + } + + RequestPortServer server() { + return RequestPortServer(_receivePort.sendPort); + } + + void _close([Object? error, StackTrace? stackTrace]) { + if (!closed) { + closed = true; + + _receivePort.close(); + _errorPort.close(); + if (error == null) { + _cancelAll(const ClosedException()); + } else { + _closeError = error; + _cancelAll(error, stackTrace); + } + } + } + + void close() { + _close(); + } + + tieToIsolate(Isolate isolate) { + _isolateDebugName = isolate.debugName; + isolate.addErrorListener(_errorPort.sendPort); + isolate.addOnExitListener(_receivePort.sendPort, response: _closeMessage); + } +} + +class SerializedPortClient { + final SendPort sendPort; + + SerializedPortClient(this.sendPort); + + ChildPortClient open() { + return ChildPortClient(sendPort); + } +} + +class ChildPortClient implements PortClient { + final SendPort sendPort; + final ReceivePort receivePort = ReceivePort(); + int _nextId = 1; + bool closed = false; + + final Map> handlers = HashMap(); + + ChildPortClient(this.sendPort) { + receivePort.listen((message) { + if (message is _PortChannelResult) { + final handler = handlers.remove(message.requestId); + assert(handler != null); + if (message.success) { + handler!.complete(message.result); + } else { + handler!.completeError(message.error, message.stackTrace); + } + } + }); + } + + @override + Future post(Object message) async { + if (closed) { + throw const ClosedException(); + } + var completer = Completer.sync(); + var id = _nextId++; + handlers[id] = completer; + sendPort.send(_RequestMessage(id, message, receivePort.sendPort)); + return await completer.future; + } + + @override + void fire(Object message) { + if (closed) { + throw ClosedException(); + } + sendPort.send(_FireMessage(message)); + } + + void _cancelAll(Object error) { + var handlers = HashMap>.from(this.handlers); + this.handlers.clear(); + for (var message in handlers.values) { + message.completeError(error); + } + } + + void close() { + closed = true; + _cancelAll(const ClosedException()); + receivePort.close(); + } +} + +class RequestPortServer { + final SendPort port; + + RequestPortServer(this.port); + + open(Future Function(Object? message) handle) { + return PortServer.forSendPort(port, handle); + } +} + +class PortServer { + final ReceivePort _receivePort = ReceivePort(); + final Future Function(Object? message) handle; + final SendPort? replyPort; + + PortServer(this.handle) : replyPort = null { + _init(); + } + + PortServer.forSendPort(SendPort port, this.handle) : replyPort = port { + port.send(_InitMessage(_receivePort.sendPort)); + _init(); + } + + SendPort get sendPort { + return _receivePort.sendPort; + } + + SerializedPortClient client() { + return SerializedPortClient(sendPort); + } + + void close() { + _receivePort.close(); + } + + void _init() { + _receivePort.listen((request) async { + if (request is _FireMessage) { + handle(request.message); + } else if (request is _RequestMessage) { + if (request.id == 0) { + // Fire and forget + handle(request.message); + } else { + final replyPort = request.reply ?? this.replyPort; + try { + var result = await handle(request.message); + replyPort!.send(_PortChannelResult.success(request.id, result)); + } catch (e, stacktrace) { + replyPort! + .send(_PortChannelResult.error(request.id, e, stacktrace)); + } + } + } + }); + } +} + +const _closeMessage = '_Close'; + +class _InitMessage { + final SendPort port; + + _InitMessage(this.port); +} + +class _FireMessage { + final Object message; + + const _FireMessage(this.message); +} + +class _RequestMessage { + final int id; + final Object message; + final SendPort? reply; + + _RequestMessage(this.id, this.message, this.reply); +} + +class ClosedException implements Exception { + const ClosedException(); + + @override + String toString() { + return 'ClosedException'; + } +} + +class IsolateError extends Error { + final Object cause; + final String? isolateDebugName; + + IsolateError({required this.cause, this.isolateDebugName}); + + @override + String toString() { + if (isolateDebugName != null) { + return 'IsolateError in $isolateDebugName: $cause'; + } else { + return 'IsolateError: $cause'; + } + } +} + +class _PortChannelResult { + final int requestId; + final bool success; + final T? _result; + final Object? _error; + final StackTrace? stackTrace; + + const _PortChannelResult.success(this.requestId, T result) + : success = true, + _error = null, + stackTrace = null, + _result = result; + const _PortChannelResult.error(this.requestId, Object error, + [this.stackTrace]) + : success = false, + _result = null, + _error = error; + + T get value { + if (success) { + return _result as T; + } else { + if (_error != null && stackTrace != null) { + Error.throwWithStackTrace(_error, stackTrace!); + } else { + throw _error!; + } + } + } + + T get result { + assert(success); + return _result as T; + } + + Object get error { + assert(!success); + return _error!; + } +} diff --git a/packages/sqlite_async/lib/src/common/port_channel_stub.dart b/packages/sqlite_async/lib/src/common/port_channel_stub.dart new file mode 100644 index 0000000..6c6e5cc --- /dev/null +++ b/packages/sqlite_async/lib/src/common/port_channel_stub.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'dart:collection'; + +typedef SendPort = Never; +typedef ReceivePort = Never; +typedef Isolate = Never; + +Never _stub() { + throw UnsupportedError('Isolates are not supported on this platform'); +} + +abstract class PortClient { + Future post(Object message); + void fire(Object message); + + factory PortClient.parent() { + return ParentPortClient(); + } + + factory PortClient.child(SendPort upstream) { + return ChildPortClient(upstream); + } +} + +class ParentPortClient implements PortClient { + late Future sendPortFuture; + SendPort? sendPort; + bool closed = false; + + Map> handlers = HashMap(); + + ParentPortClient(); + + Future get ready async { + await sendPortFuture; + } + + @override + Future post(Object message) async { + _stub(); + } + + @override + void fire(Object message) async { + _stub(); + } + + RequestPortServer server() { + _stub(); + } + + void close() { + _stub(); + } + + tieToIsolate(Isolate isolate) { + _stub(); + } +} + +class SerializedPortClient { + final SendPort sendPort; + + SerializedPortClient(this.sendPort); + + ChildPortClient open() { + return ChildPortClient(sendPort); + } +} + +class ChildPortClient implements PortClient { + final SendPort sendPort; + ReceivePort get receivePort => _stub(); + bool closed = false; + + final Map> handlers = HashMap(); + + ChildPortClient(this.sendPort); + + @override + Future post(Object message) async { + _stub(); + } + + @override + void fire(Object message) { + _stub(); + } + + void close() { + _stub(); + } +} + +class RequestPortServer { + final SendPort port; + + RequestPortServer(this.port); + + open(Future Function(Object? message) handle) { + _stub(); + } +} + +class PortServer { + final Future Function(Object? message) handle; + final SendPort? replyPort; + + PortServer(this.handle) : replyPort = null; + + PortServer.forSendPort(SendPort port, this.handle) : replyPort = port; + + SendPort get sendPort { + _stub(); + } + + SerializedPortClient client() { + return SerializedPortClient(sendPort); + } + + void close() { + _stub(); + } +} + +class ClosedException implements Exception { + const ClosedException(); + + @override + String toString() { + return 'ClosedException'; + } +} + +class IsolateError extends Error { + final Object cause; + final String? isolateDebugName; + + IsolateError({required this.cause, this.isolateDebugName}); + + @override + String toString() { + if (isolateDebugName != null) { + return 'IsolateError in $isolateDebugName: $cause'; + } else { + return 'IsolateError: $cause'; + } + } +} diff --git a/packages/sqlite_async/lib/src/common/sqlite_database.dart b/packages/sqlite_async/lib/src/common/sqlite_database.dart index f8e0be5..3201135 100644 --- a/packages/sqlite_async/lib/src/common/sqlite_database.dart +++ b/packages/sqlite_async/lib/src/common/sqlite_database.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:sqlite_async/src/common/abstract_open_factory.dart'; import 'package:sqlite_async/src/common/isolate_connection_factory.dart'; +import 'package:sqlite_async/src/impl/single_connection_database.dart'; import 'package:sqlite_async/src/impl/sqlite_database_impl.dart'; import 'package:sqlite_async/src/sqlite_options.dart'; import 'package:sqlite_async/src/sqlite_queries.dart'; @@ -38,6 +39,14 @@ mixin SqliteDatabaseMixin implements SqliteConnection, SqliteQueries { /// /// Use this to access the database in background isolates. IsolateConnectionFactory isolateConnectionFactory(); + + /// Locks all underlying connections making up this database, and gives [block] access to all of them at once. + /// This can be useful to run the same statement on all connections. For instance, + /// ATTACHing a database, that is expected to be available in all connections. + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block); } /// A SQLite database instance. @@ -82,4 +91,24 @@ abstract class SqliteDatabase {int maxReaders = SqliteDatabase.defaultMaxReaders}) { return SqliteDatabaseImpl.withFactory(openFactory, maxReaders: maxReaders); } + + /// Opens a [SqliteDatabase] that only wraps an underlying connection. + /// + /// This function may be useful in some instances like tests, but should not + /// typically be used by applications. Compared to the other ways to open + /// databases, it has the following downsides: + /// + /// 1. No connection pool / concurrent readers for native databases. + /// 2. No reliable update notifications on the web. + /// 3. There is no reliable transaction management in Dart, and opening the + /// same database with [SqliteDatabase.singleConnection] multiple times + /// may cause "database is locked" errors. + /// + /// Together with [SqliteConnection.synchronousWrapper], this can be used to + /// open in-memory databases (e.g. via [SqliteOpenFactory.open]). That + /// bypasses most convenience features, but may still be useful for + /// short-lived databases used in tests. + factory SqliteDatabase.singleConnection(SqliteConnection connection) { + return SingleConnectionDatabase(connection); + } } diff --git a/packages/sqlite_async/lib/src/impl/context.dart b/packages/sqlite_async/lib/src/impl/context.dart new file mode 100644 index 0000000..0e3eef6 --- /dev/null +++ b/packages/sqlite_async/lib/src/impl/context.dart @@ -0,0 +1,208 @@ +import 'package:sqlite3/common.dart'; + +import '../sqlite_connection.dart'; + +/// A context that can be used to run both reading and writing queries - +/// basically a [SqliteWriteContext] without the ability to start transactions. +/// +/// Instances of this are not given out to clients - instead, they are wrapped +/// with [ScopedReadContext] and [ScopedWriteContext] after obtaining a lock. +/// Those wrapped views have a shorter lifetime (they can be closed +/// independently, and verify that they're not being used after being closed). +abstract base class UnscopedContext implements SqliteReadContext { + Future execute(String sql, List parameters); + Future executeBatch(String sql, List> parameterSets); + + /// Returns an [UnscopedContext] useful as the outermost transaction. + /// + /// This is called by [ScopedWriteContext.writeTransaction] _after_ executing + /// the first `BEGIN` statement. + /// This is used on the web to assert that the auto-commit state is false + /// before running statements. + UnscopedContext interceptOutermostTransaction() { + return this; + } +} + +/// A view over an [UnscopedContext] implementing [SqliteReadContext]. +final class ScopedReadContext implements SqliteReadContext { + final UnscopedContext _context; + + /// Whether this context view is locked on an inner operation like a + /// transaction. + /// + /// We don't use a mutex because we don't want to serialize access - we just + /// want to forbid concurrent operations. + bool _isLocked = false; + + /// Whether this particular view of a read context has been closed, e.g. + /// because the callback owning it has returned. + bool _closed = false; + + ScopedReadContext(this._context); + + void _checkNotLocked() { + _checkStillOpen(); + + if (_isLocked) { + throw StateError( + 'The context from the callback was locked, e.g. due to a nested ' + 'transaction.'); + } + } + + void _checkStillOpen() { + if (_closed) { + throw StateError('This context to a callback is no longer open. ' + 'Make sure to await all statements on a database to avoid a context ' + 'still being used after its callback has finished.'); + } + } + + @override + bool get closed => _closed || _context.closed; + + @override + Future computeWithDatabase( + Future Function(CommonDatabase db) compute) async { + _checkNotLocked(); + return await _context.computeWithDatabase(compute); + } + + @override + Future get(String sql, [List parameters = const []]) async { + _checkNotLocked(); + return _context.get(sql, parameters); + } + + @override + Future getAll(String sql, + [List parameters = const []]) async { + _checkNotLocked(); + return await _context.getAll(sql, parameters); + } + + @override + Future getAutoCommit() async { + _checkStillOpen(); + return _context.getAutoCommit(); + } + + @override + Future getOptional(String sql, + [List parameters = const []]) async { + _checkNotLocked(); + return _context.getOptional(sql, parameters); + } + + void invalidate() => _closed = true; + + /// Creates a short-lived wrapper around the [unsafe] context to safely give + /// [callback] read-access to the database. + /// + /// Assumes that a read lock providing sound access to the inner + /// [UnscopedContext] is held until this future returns. + static Future assumeReadLock( + UnscopedContext unsafe, + Future Function(SqliteReadContext) callback, + ) async { + final scoped = ScopedReadContext(unsafe); + try { + return await callback(scoped); + } finally { + scoped.invalidate(); + } + } +} + +final class ScopedWriteContext extends ScopedReadContext + implements SqliteWriteContext { + /// The "depth" of virtual nested transaction. + /// + /// A value of `0` indicates that this is operating outside of a transaction. + /// A value of `1` indicates a regular transaction (guarded with `BEGIN` and + /// `COMMIT` statements). + /// All higher values indicate a nested transaction implemented with + /// savepoint statements. + final int transactionDepth; + + ScopedWriteContext(super._context, {this.transactionDepth = 0}); + + @override + Future execute(String sql, + [List parameters = const []]) async { + _checkNotLocked(); + return await _context.execute(sql, parameters); + } + + @override + Future executeBatch( + String sql, List> parameterSets) async { + _checkNotLocked(); + + return await _context.executeBatch(sql, parameterSets); + } + + @override + Future writeTransaction( + Future Function(SqliteWriteContext tx) callback) async { + _checkNotLocked(); + final (begin, commit, rollback) = _beginCommitRollback(transactionDepth); + ScopedWriteContext? inner; + + final innerContext = transactionDepth == 0 + ? _context.interceptOutermostTransaction() + : _context; + + try { + _isLocked = true; + + await _context.execute(begin, const []); + + inner = ScopedWriteContext(innerContext, + transactionDepth: transactionDepth + 1); + final result = await callback(inner); + await innerContext.execute(commit, const []); + return result; + } catch (e) { + try { + await innerContext.execute(rollback, const []); + } catch (e) { + // In rare cases, a ROLLBACK may fail. + // Safe to ignore. + } + rethrow; + } finally { + _isLocked = false; + inner?.invalidate(); + } + } + + static (String, String, String) _beginCommitRollback(int level) { + return switch (level) { + 0 => ('BEGIN IMMEDIATE', 'COMMIT', 'ROLLBACK'), + final nested => ( + 'SAVEPOINT s$nested', + 'RELEASE s$nested', + 'ROLLBACK TO s$nested' + ) + }; + } + + /// Creates a short-lived wrapper around the [unsafe] context to safely give + /// [callback] access to the database. + /// + /// Assumes that a write lock providing sound access to the inner + /// [UnscopedContext] is held until this future returns. + static Future assumeWriteLock( + UnscopedContext unsafe, + Future Function(SqliteWriteContext) callback, + ) async { + final scoped = ScopedWriteContext(unsafe); + try { + return await callback(scoped); + } finally { + scoped.invalidate(); + } + } +} diff --git a/packages/sqlite_async/lib/src/impl/isolate_connection_factory_impl.dart b/packages/sqlite_async/lib/src/impl/isolate_connection_factory_impl.dart index 4812688..ec70cb0 100644 --- a/packages/sqlite_async/lib/src/impl/isolate_connection_factory_impl.dart +++ b/packages/sqlite_async/lib/src/impl/isolate_connection_factory_impl.dart @@ -2,4 +2,4 @@ export 'stub_isolate_connection_factory.dart' // ignore: uri_does_not_exist if (dart.library.io) '../native/native_isolate_connection_factory.dart' // ignore: uri_does_not_exist - if (dart.library.html) '../web/web_isolate_connection_factory.dart'; + if (dart.library.js_interop) '../web/web_isolate_connection_factory.dart'; diff --git a/packages/sqlite_async/lib/src/impl/mutex_impl.dart b/packages/sqlite_async/lib/src/impl/mutex_impl.dart index 6b77de7..0f45fe4 100644 --- a/packages/sqlite_async/lib/src/impl/mutex_impl.dart +++ b/packages/sqlite_async/lib/src/impl/mutex_impl.dart @@ -2,4 +2,4 @@ export 'stub_mutex.dart' // ignore: uri_does_not_exist if (dart.library.io) '../native/native_isolate_mutex.dart' // ignore: uri_does_not_exist - if (dart.library.html) '../web/web_mutex.dart'; + if (dart.library.js_interop) '../web/web_mutex.dart'; diff --git a/packages/sqlite_async/lib/src/impl/open_factory_impl.dart b/packages/sqlite_async/lib/src/impl/open_factory_impl.dart index e8ce4e1..a7a27ac 100644 --- a/packages/sqlite_async/lib/src/impl/open_factory_impl.dart +++ b/packages/sqlite_async/lib/src/impl/open_factory_impl.dart @@ -2,4 +2,4 @@ export 'stub_sqlite_open_factory.dart' // ignore: uri_does_not_exist if (dart.library.io) '../native/native_sqlite_open_factory.dart' // ignore: uri_does_not_exist - if (dart.library.html) '../web/web_sqlite_open_factory.dart'; + if (dart.library.js_interop) '../web/web_sqlite_open_factory.dart'; diff --git a/packages/sqlite_async/lib/src/impl/single_connection_database.dart b/packages/sqlite_async/lib/src/impl/single_connection_database.dart new file mode 100644 index 0000000..7ca4357 --- /dev/null +++ b/packages/sqlite_async/lib/src/impl/single_connection_database.dart @@ -0,0 +1,68 @@ +import 'package:sqlite3/common.dart'; +import 'package:sqlite_async/sqlite_async.dart'; + +/// A database implementation that delegates everything to a single connection. +/// +/// This doesn't provide an automatic connection pool or the web worker +/// management, but it can still be useful in cases like unit tests where those +/// features might not be necessary. Since only a single sqlite connection is +/// used internally, this also allows using in-memory databases. +final class SingleConnectionDatabase + with SqliteQueries, SqliteDatabaseMixin + implements SqliteDatabase { + final SqliteConnection connection; + + SingleConnectionDatabase(this.connection); + + @override + Future close() => connection.close(); + + @override + bool get closed => connection.closed; + + @override + Future getAutoCommit() => connection.getAutoCommit(); + + @override + Future get isInitialized => Future.value(); + + @override + IsolateConnectionFactory isolateConnectionFactory() { + throw UnsupportedError( + "SqliteDatabase.singleConnection instances can't be used across " + 'isolates.'); + } + + @override + int get maxReaders => 1; + + @override + AbstractDefaultSqliteOpenFactory get openFactory => + throw UnimplementedError(); + + @override + Future readLock(Future Function(SqliteReadContext tx) callback, + {Duration? lockTimeout, String? debugContext}) { + return connection.readLock(callback, + lockTimeout: lockTimeout, debugContext: debugContext); + } + + @override + Stream get updates => + connection.updates ?? const Stream.empty(); + + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {Duration? lockTimeout, String? debugContext}) { + return connection.writeLock(callback, + lockTimeout: lockTimeout, debugContext: debugContext); + } + + @override + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) { + return writeLock((_) => block(connection, [])); + } +} diff --git a/packages/sqlite_async/lib/src/impl/sqlite_database_impl.dart b/packages/sqlite_async/lib/src/impl/sqlite_database_impl.dart index a2bcd20..cf1dfbb 100644 --- a/packages/sqlite_async/lib/src/impl/sqlite_database_impl.dart +++ b/packages/sqlite_async/lib/src/impl/sqlite_database_impl.dart @@ -2,4 +2,4 @@ export 'stub_sqlite_database.dart' // ignore: uri_does_not_exist if (dart.library.io) '../native/database/native_sqlite_database.dart' // ignore: uri_does_not_exist - if (dart.library.html) '../web/database/web_sqlite_database.dart'; + if (dart.library.js_interop) '../web/database/web_sqlite_database.dart'; diff --git a/packages/sqlite_async/lib/src/impl/stub_mutex.dart b/packages/sqlite_async/lib/src/impl/stub_mutex.dart index 1e700fa..aefb9e6 100644 --- a/packages/sqlite_async/lib/src/impl/stub_mutex.dart +++ b/packages/sqlite_async/lib/src/impl/stub_mutex.dart @@ -1,6 +1,10 @@ import 'package:sqlite_async/src/common/mutex.dart'; class MutexImpl implements Mutex { + String? identifier; + + MutexImpl({this.identifier}); + @override Future close() { throw UnimplementedError(); diff --git a/packages/sqlite_async/lib/src/impl/stub_sqlite_database.dart b/packages/sqlite_async/lib/src/impl/stub_sqlite_database.dart index 29db641..ee254f3 100644 --- a/packages/sqlite_async/lib/src/impl/stub_sqlite_database.dart +++ b/packages/sqlite_async/lib/src/impl/stub_sqlite_database.dart @@ -64,4 +64,12 @@ class SqliteDatabaseImpl Future getAutoCommit() { throw UnimplementedError(); } + + @override + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) { + throw UnimplementedError(); + } } diff --git a/packages/sqlite_async/lib/src/native/database/connection_pool.dart b/packages/sqlite_async/lib/src/native/database/connection_pool.dart index 56d9c12..8dab27e 100644 --- a/packages/sqlite_async/lib/src/native/database/connection_pool.dart +++ b/packages/sqlite_async/lib/src/native/database/connection_pool.dart @@ -31,6 +31,8 @@ class SqliteConnectionPool with SqliteQueries implements SqliteConnection { final MutexImpl mutex; + int _runningWithAllConnectionsCount = 0; + @override bool closed = false; @@ -88,6 +90,14 @@ class SqliteConnectionPool with SqliteQueries implements SqliteConnection { return; } + if (_availableReadConnections.isEmpty && + _runningWithAllConnectionsCount > 0) { + // Wait until [withAllConnections] is done. Otherwise we could spawn a new + // reader while the user is configuring all the connections, + // e.g. a global open factory configuration shared across all connections. + return; + } + var nextItem = _queue.removeFirst(); while (nextItem.completer.isCompleted) { // This item already timed out - try the next one if available @@ -221,6 +231,77 @@ class SqliteConnectionPool with SqliteQueries implements SqliteConnection { // read-only connections first. await _writeConnection?.close(); } + + @override + Future refreshSchema() async { + final toRefresh = _allReadConnections.toList(); + + await _writeConnection?.refreshSchema(); + + for (var connection in toRefresh) { + await connection.refreshSchema(); + } + } + + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) async { + try { + _runningWithAllConnectionsCount++; + + final blockCompleter = Completer(); + final (write, reads) = await _lockAllConns(blockCompleter); + + try { + final res = await block(write, reads); + blockCompleter.complete(res); + return res; + } catch (e, st) { + blockCompleter.completeError(e, st); + rethrow; + } + } finally { + _runningWithAllConnectionsCount--; + + // Continue processing any pending read requests that may have been queued while + // the block was running. + Timer.run(_nextRead); + } + } + + /// Locks all connections, returning the acquired contexts. + /// We pass a completer that would be called after the locks are taken. + Future<(SqliteWriteContext, List)> _lockAllConns( + Completer lockCompleter) async { + final List> readLockedCompleters = []; + final Completer writeLockedCompleter = Completer(); + + // Take the write lock + writeLock((ctx) { + writeLockedCompleter.complete(ctx); + return lockCompleter.future; + }); + + // Take all the read locks + for (final readConn in _allReadConnections) { + final completer = Completer(); + readLockedCompleters.add(completer); + + readConn.readLock((ctx) { + completer.complete(ctx); + return lockCompleter.future; + }); + } + + // Wait after all locks are taken + final [writer as SqliteWriteContext, ...readers] = await Future.wait([ + writeLockedCompleter.future, + ...readLockedCompleters.map((e) => e.future) + ]); + + return (writer, readers); + } } typedef ReadCallback = Future Function(SqliteReadContext tx); diff --git a/packages/sqlite_async/lib/src/native/database/native_sqlite_connection_impl.dart b/packages/sqlite_async/lib/src/native/database/native_sqlite_connection_impl.dart index b7ef76b..e90739e 100644 --- a/packages/sqlite_async/lib/src/native/database/native_sqlite_connection_impl.dart +++ b/packages/sqlite_async/lib/src/native/database/native_sqlite_connection_impl.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'dart:isolate'; import 'package:sqlite3/sqlite3.dart' as sqlite; @@ -10,8 +11,10 @@ import 'package:sqlite_async/src/native/native_isolate_mutex.dart'; import 'package:sqlite_async/src/sqlite_connection.dart'; import 'package:sqlite_async/src/sqlite_queries.dart'; import 'package:sqlite_async/src/update_notification.dart'; +import 'package:sqlite_async/src/utils/profiler.dart'; import 'package:sqlite_async/src/utils/shared_utils.dart'; +import '../../impl/context.dart'; import 'upstream_updates.dart'; typedef TxCallback = Future Function(CommonDatabase db); @@ -33,20 +36,24 @@ class SqliteConnectionImpl final String? debugName; final bool readOnly; - SqliteConnectionImpl( - {required openFactory, - required Mutex mutex, - SerializedPortClient? upstreamPort, - Stream? updates, - this.debugName, - this.readOnly = false, - bool primary = false}) - : _writeMutex = mutex { - isInitialized = _isolateClient.ready; + final bool profileQueries; + bool _didOpenSuccessfully = false; + + SqliteConnectionImpl({ + required AbstractDefaultSqliteOpenFactory openFactory, + required Mutex mutex, + SerializedPortClient? upstreamPort, + Stream? updates, + this.debugName, + this.readOnly = false, + bool primary = false, + }) : _writeMutex = mutex, + profileQueries = openFactory.sqliteOptions.profileQueries { this.upstreamPort = upstreamPort ?? listenForEvents(); // Accept an incoming stream of updates, or expose one if not given. this.updates = updates ?? updatesController.stream; - _open(openFactory, primary: primary, upstreamPort: this.upstreamPort); + isInitialized = + _open(openFactory, primary: primary, upstreamPort: this.upstreamPort); } Future get ready async { @@ -58,6 +65,11 @@ class SqliteConnectionImpl return _isolateClient.closed; } + _UnsafeContext _context() { + return _UnsafeContext( + _isolateClient, profileQueries ? TimelineTask() : null); + } + @override Future getAutoCommit() async { if (closed) { @@ -65,7 +77,7 @@ class SqliteConnectionImpl } // We use a _TransactionContext without a lock here. // It is safe to call this in the middle of another transaction. - final ctx = _TransactionContext(_isolateClient); + final ctx = _context(); try { return await ctx.getAutoCommit(); } finally { @@ -90,6 +102,7 @@ class SqliteConnectionImpl _isolateClient.tieToIsolate(_isolate); _isolate.resume(_isolate.pauseCapability!); await _isolateClient.ready; + _didOpenSuccessfully = true; }); } @@ -97,15 +110,18 @@ class SqliteConnectionImpl Future close() async { eventsPort?.close(); await _connectionMutex.lock(() async { - if (readOnly) { - await _isolateClient.post(const _SqliteIsolateConnectionClose()); - } else { - // In some cases, disposing a write connection lock the database. - // We use the lock here to avoid "database is locked" errors. - await _writeMutex.lock(() async { + if (_didOpenSuccessfully) { + if (readOnly) { await _isolateClient.post(const _SqliteIsolateConnectionClose()); - }); + } else { + // In some cases, disposing a write connection lock the database. + // We use the lock here to avoid "database is locked" errors. + await _writeMutex.lock(() async { + await _isolateClient.post(const _SqliteIsolateConnectionClose()); + }); + } } + _isolate.kill(); }); } @@ -120,9 +136,9 @@ class SqliteConnectionImpl // Private lock to synchronize this with other statements on the same connection, // to ensure that transactions aren't interleaved. return _connectionMutex.lock(() async { - final ctx = _TransactionContext(_isolateClient); + final ctx = _context(); try { - return await callback(ctx); + return await ScopedReadContext.assumeReadLock(ctx, callback); } finally { await ctx.close(); } @@ -143,9 +159,9 @@ class SqliteConnectionImpl } // DB lock so that only one write happens at a time return await _writeMutex.lock(() async { - final ctx = _TransactionContext(_isolateClient); + final ctx = _context(); try { - return await callback(ctx); + return await ScopedWriteContext.assumeWriteLock(ctx, callback); } finally { await ctx.close(); } @@ -162,12 +178,14 @@ class SqliteConnectionImpl int _nextCtxId = 1; -class _TransactionContext implements SqliteWriteContext { +final class _UnsafeContext extends UnscopedContext { final PortClient _sendPort; bool _closed = false; final int ctxId = _nextCtxId++; - _TransactionContext(this._sendPort); + final TimelineTask? task; + + _UnsafeContext(this._sendPort, this.task); @override bool get closed { @@ -187,8 +205,13 @@ class _TransactionContext implements SqliteWriteContext { throw sqlite.SqliteException(0, 'Transaction closed', null, sql); } try { - var future = _sendPort.post( - _SqliteIsolateStatement(ctxId, sql, parameters, readOnly: false)); + var future = _sendPort.post(_SqliteIsolateStatement( + ctxId, + sql, + parameters, + readOnly: false, + timelineTask: task?.pass(), + )); return await future; } on sqlite.SqliteException catch (e) { @@ -280,97 +303,102 @@ Future _sqliteConnectionIsolateInner(_SqliteConnectionParams params, final server = params.portServer; final commandPort = ReceivePort(); - Timer? updateDebouncer; - Set updatedTables = {}; + db.updatedTables.listen((changedTables) { + client.fire(UpdateNotification(changedTables)); + }); + int? txId; Object? txError; - void maybeFireUpdates() { - if (updatedTables.isNotEmpty) { - client.fire(UpdateNotification(updatedTables)); - updatedTables.clear(); - updateDebouncer?.cancel(); - updateDebouncer = null; - } - } - - db.updates.listen((event) { - updatedTables.add(event.tableName); - - // This handles two cases: - // 1. Update arrived after _SqliteIsolateClose (not sure if this could happen). - // 2. Long-running _SqliteIsolateClosure that should fire updates while running. - updateDebouncer ??= - Timer(const Duration(milliseconds: 10), maybeFireUpdates); - }); - - server.open((data) async { - if (data is _SqliteIsolateClose) { + ResultSet runStatement(_SqliteIsolateStatement data) { + if (data.sql == 'BEGIN' || data.sql == 'BEGIN IMMEDIATE') { if (txId != null) { - if (!db.autocommit) { - db.execute('ROLLBACK'); - } - txId = null; - txError = null; - throw sqlite.SqliteException( - 0, 'Transaction must be closed within the read or write lock'); + // This will error on db.select } - // We would likely have received updates by this point - fire now. - maybeFireUpdates(); - return null; - } else if (data is _SqliteIsolateStatement) { - if (data.sql == 'BEGIN' || data.sql == 'BEGIN IMMEDIATE') { - if (txId != null) { - // This will error on db.select + txId = data.ctxId; + } else if (txId != null && txId != data.ctxId) { + // Locks should prevent this from happening + throw sqlite.SqliteException( + 0, 'Mixed transactions: $txId and ${data.ctxId}'); + } else if (data.sql == 'ROLLBACK') { + // This is the only valid way to clear an error + txError = null; + txId = null; + } else if (txError != null) { + // Any statement (including COMMIT) after the first error will also error, until the + // transaction is aborted. + throw txError!; + } else if (data.sql == 'COMMIT' || data.sql == 'END TRANSACTION') { + txId = null; + } + try { + final result = db.select(data.sql, mapParameters(data.args)); + return result; + } catch (err) { + if (txId != null) { + if (db.autocommit) { + // Transaction rolled back + txError = sqlite.SqliteException(0, + 'Transaction rolled back by earlier statement: ${err.toString()}'); + } else { + // Recoverable error } - txId = data.ctxId; - } else if (txId != null && txId != data.ctxId) { - // Locks should prevent this from happening - throw sqlite.SqliteException( - 0, 'Mixed transactions: $txId and ${data.ctxId}'); - } else if (data.sql == 'ROLLBACK') { - // This is the only valid way to clear an error - txError = null; - txId = null; - } else if (txError != null) { - // Any statement (including COMMIT) after the first error will also error, until the - // transaction is aborted. - throw txError!; - } else if (data.sql == 'COMMIT' || data.sql == 'END TRANSACTION') { - txId = null; } - try { - final result = db.select(data.sql, mapParameters(data.args)); - return result; - } catch (err) { + rethrow; + } + } + + Future handle(_RemoteIsolateRequest data, TimelineTask? task) async { + switch (data) { + case _SqliteIsolateClose(): + // This is a transaction close message if (txId != null) { - if (db.autocommit) { - // Transaction rolled back - txError = sqlite.SqliteException(0, - 'Transaction rolled back by earlier statement: ${err.toString()}'); - } else { - // Recoverable error + if (!db.autocommit) { + db.execute('ROLLBACK'); } + txId = null; + txError = null; + throw sqlite.SqliteException( + 0, 'Transaction must be closed within the read or write lock'); } - rethrow; - } - } else if (data is _SqliteIsolateClosure) { - try { + return null; + case _SqliteIsolateStatement(): + return task.timeSync( + 'execute_remote', + () => runStatement(data), + sql: data.sql, + parameters: data.args, + ); + case _SqliteIsolateClosure(): return await data.cb(db); - } finally { - maybeFireUpdates(); - } - } else if (data is _SqliteIsolateConnectionClose) { - db.dispose(); - return null; - } else { + case _SqliteIsolateConnectionClose(): + db.dispose(); + return null; + } + } + + server.open((data) async { + if (data is! _RemoteIsolateRequest) { throw ArgumentError('Unknown data type $data'); } + + final task = switch (data.timelineTask) { + null => null, + final id => TimelineTask.withTaskId(id), + }; + + return await handle(data, task); }); commandPort.listen((data) async {}); } +sealed class _RemoteIsolateRequest { + final int? timelineTask; + + const _RemoteIsolateRequest({required this.timelineTask}); +} + class _SqliteConnectionParams { final RequestPortServer portServer; final bool readOnly; @@ -387,28 +415,28 @@ class _SqliteConnectionParams { required this.primary}); } -class _SqliteIsolateStatement { +class _SqliteIsolateStatement extends _RemoteIsolateRequest { final int ctxId; final String sql; final List args; final bool readOnly; _SqliteIsolateStatement(this.ctxId, this.sql, this.args, - {this.readOnly = false}); + {this.readOnly = false, super.timelineTask}); } -class _SqliteIsolateClosure { +class _SqliteIsolateClosure extends _RemoteIsolateRequest { final TxCallback cb; - _SqliteIsolateClosure(this.cb); + _SqliteIsolateClosure(this.cb, {super.timelineTask}); } -class _SqliteIsolateClose { +class _SqliteIsolateClose extends _RemoteIsolateRequest { final int ctxId; - const _SqliteIsolateClose(this.ctxId); + const _SqliteIsolateClose(this.ctxId, {super.timelineTask}); } -class _SqliteIsolateConnectionClose { - const _SqliteIsolateConnectionClose(); +class _SqliteIsolateConnectionClose extends _RemoteIsolateRequest { + const _SqliteIsolateConnectionClose({super.timelineTask}); } diff --git a/packages/sqlite_async/lib/src/native/database/native_sqlite_database.dart b/packages/sqlite_async/lib/src/native/database/native_sqlite_database.dart index 998fb79..22cacf3 100644 --- a/packages/sqlite_async/lib/src/native/database/native_sqlite_database.dart +++ b/packages/sqlite_async/lib/src/native/database/native_sqlite_database.dart @@ -34,8 +34,8 @@ class SqliteDatabaseImpl @override @protected - // Native doesn't require any asynchronous initialization - late Future isInitialized = Future.value(); + // ignore: invalid_use_of_protected_member + late Future isInitialized = _internalConnection.isInitialized; late final SqliteConnectionImpl _internalConnection; late final SqliteConnectionPool _pool; @@ -166,4 +166,17 @@ class SqliteDatabaseImpl readOnly: false, openFactory: openFactory); } + + @override + Future refreshSchema() { + return _pool.refreshSchema(); + } + + @override + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) { + return _pool.withAllConnections(block); + } } diff --git a/packages/sqlite_async/lib/src/native/native_isolate_mutex.dart b/packages/sqlite_async/lib/src/native/native_isolate_mutex.dart index 23e5443..7d82f68 100644 --- a/packages/sqlite_async/lib/src/native/native_isolate_mutex.dart +++ b/packages/sqlite_async/lib/src/native/native_isolate_mutex.dart @@ -7,8 +7,8 @@ import 'package:sqlite_async/src/common/mutex.dart'; import 'package:sqlite_async/src/common/port_channel.dart'; abstract class MutexImpl implements Mutex { - factory MutexImpl() { - return SimpleMutex(); + factory MutexImpl({String? identifier}) { + return SimpleMutex(identifier: identifier); } } @@ -19,12 +19,13 @@ class SimpleMutex implements MutexImpl { // Adapted from https://github.com/tekartik/synchronized.dart/blob/master/synchronized/lib/src/basic_lock.dart Future? last; + String? identifier; // Hack to make sure the Mutex is not copied to another isolate. // ignore: unused_field final Finalizer _f = Finalizer((_) {}); - SimpleMutex(); + SimpleMutex({this.identifier}); bool get locked => last != null; diff --git a/packages/sqlite_async/lib/src/sqlite_connection.dart b/packages/sqlite_async/lib/src/sqlite_connection.dart index f92d318..0e360fc 100644 --- a/packages/sqlite_async/lib/src/sqlite_connection.dart +++ b/packages/sqlite_async/lib/src/sqlite_connection.dart @@ -1,10 +1,14 @@ import 'dart:async'; import 'package:sqlite3/common.dart' as sqlite; +import 'package:sqlite_async/mutex.dart'; +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/src/update_notification.dart'; +import 'common/connection/sync_sqlite_connection.dart'; + /// Abstract class representing calls available in a read-only or read-write context. -abstract class SqliteReadContext { +abstract interface class SqliteReadContext { /// Execute a read-only (SELECT) query and return the results. Future getAll(String sql, [List parameters = const []]); @@ -62,7 +66,7 @@ abstract class SqliteReadContext { } /// Abstract class representing calls available in a read-write context. -abstract class SqliteWriteContext extends SqliteReadContext { +abstract interface class SqliteWriteContext extends SqliteReadContext { /// Execute a write query (INSERT, UPDATE, DELETE) and return the results (if any). Future execute(String sql, [List parameters = const []]); @@ -71,10 +75,48 @@ abstract class SqliteWriteContext extends SqliteReadContext { /// parameter set. This is faster than executing separately with each /// parameter set. Future executeBatch(String sql, List> parameterSets); + + /// Open a read-write transaction on this write context. + /// + /// When called on a [SqliteConnection], this takes a global lock - only one + /// write write transaction can execute against the database at a time. This + /// applies even when constructing separate [SqliteDatabase] instances for the + /// same database file. + /// + /// Statements within the transaction must be done on the provided + /// [SqliteWriteContext] - attempting statements on the [SqliteConnection] + /// instance will error. + /// It is forbidden to use the [SqliteWriteContext] after the [callback] + /// completes. + Future writeTransaction( + Future Function(SqliteWriteContext tx) callback); } /// Abstract class representing a connection to the SQLite database. -abstract class SqliteConnection extends SqliteWriteContext { +/// +/// This package typically pools multiple [SqliteConnection] instances into a +/// managed [SqliteDatabase] automatically. +abstract interface class SqliteConnection extends SqliteWriteContext { + /// Default constructor for subclasses. + SqliteConnection(); + + /// Creates a [SqliteConnection] instance that wraps a raw [CommonDatabase] + /// from the `sqlite3` package. + /// + /// Users should not typically create connections manually at all. Instead, + /// open a [SqliteDatabase] through a factory. In special scenarios where it + /// may be easier to wrap a [raw] databases (like unit tests), this method + /// may be used as an escape hatch for the asynchronous wrappers provided by + /// this package. + /// + /// When [profileQueries] is enabled (it's enabled by default outside of + /// release builds, queries are posted to the `dart:developer` timeline). + factory SqliteConnection.synchronousWrapper(CommonDatabase raw, + {Mutex? mutex, bool? profileQueries}) { + return SyncSqliteConnection(raw, mutex ?? Mutex(), + profileQueries: profileQueries); + } + /// Reports table change update notifications Stream? get updates; @@ -96,6 +138,7 @@ abstract class SqliteConnection extends SqliteWriteContext { /// Statements within the transaction must be done on the provided /// [SqliteWriteContext] - attempting statements on the [SqliteConnection] /// instance will error. + @override Future writeTransaction( Future Function(SqliteWriteContext tx) callback, {Duration? lockTimeout}); @@ -130,6 +173,10 @@ abstract class SqliteConnection extends SqliteWriteContext { Future close(); + /// Ensures that all connections are aware of the latest schema changes applied (if any). + /// Queries and watch calls can potentially use outdated schema information after a schema update. + Future refreshSchema(); + /// Returns true if the connection is closed @override bool get closed; diff --git a/packages/sqlite_async/lib/src/sqlite_options.dart b/packages/sqlite_async/lib/src/sqlite_options.dart index 3767213..0489f6b 100644 --- a/packages/sqlite_async/lib/src/sqlite_options.dart +++ b/packages/sqlite_async/lib/src/sqlite_options.dart @@ -30,19 +30,27 @@ class SqliteOptions { /// Set to null or [Duration.zero] to fail immediately when the database is locked. final Duration? lockTimeout; - const SqliteOptions.defaults() - : journalMode = SqliteJournalMode.wal, - journalSizeLimit = 6 * 1024 * 1024, // 1.5x the default checkpoint size - synchronous = SqliteSynchronous.normal, - webSqliteOptions = const WebSqliteOptions.defaults(), - lockTimeout = const Duration(seconds: 30); - - const SqliteOptions( - {this.journalMode = SqliteJournalMode.wal, - this.journalSizeLimit = 6 * 1024 * 1024, - this.synchronous = SqliteSynchronous.normal, - this.webSqliteOptions = const WebSqliteOptions.defaults(), - this.lockTimeout = const Duration(seconds: 30)}); + /// Whether queries should be added to the `dart:developer` timeline. + /// + /// By default, this is enabled if the `dart.vm.product` compile-time variable + /// is not set to `true`. For Flutter apps, this means that [profileQueries] + /// is enabled by default in debug and profile mode. + final bool profileQueries; + + const factory SqliteOptions.defaults() = SqliteOptions; + + const SqliteOptions({ + this.journalMode = SqliteJournalMode.wal, + this.journalSizeLimit = 6 * 1024 * 1024, + this.synchronous = SqliteSynchronous.normal, + this.webSqliteOptions = const WebSqliteOptions.defaults(), + this.lockTimeout = const Duration(seconds: 30), + this.profileQueries = _profileQueriesByDefault, + }); + + // https://api.flutter.dev/flutter/foundation/kReleaseMode-constant.html + static const _profileQueriesByDefault = + !bool.fromEnvironment('dart.vm.product'); } /// SQLite journal mode. Set on the primary connection. diff --git a/packages/sqlite_async/lib/src/sqlite_queries.dart b/packages/sqlite_async/lib/src/sqlite_queries.dart index d0eab7a..367d23f 100644 --- a/packages/sqlite_async/lib/src/sqlite_queries.dart +++ b/packages/sqlite_async/lib/src/sqlite_queries.dart @@ -44,25 +44,23 @@ mixin SqliteQueries implements SqliteWriteContext, SqliteConnection { Stream watch(String sql, {List parameters = const [], Duration throttle = const Duration(milliseconds: 30), - Iterable? triggerOnTables}) async* { + Iterable? triggerOnTables}) { assert(updates != null, 'updates stream must be provided to allow query watching'); - final tables = - triggerOnTables ?? await getSourceTables(this, sql, parameters); - final filteredStream = - updates!.transform(UpdateNotification.filterTablesTransformer(tables)); - final throttledStream = UpdateNotification.throttleStream( - filteredStream, throttle, - addOne: UpdateNotification.empty()); - // FIXME: - // When the subscription is cancelled, this performs a final query on the next - // update. - // The loop only stops once the "yield" is reached. - // Using asyncMap instead of a generator would solve it, but then the body - // here can't be async for getSourceTables(). - await for (var _ in throttledStream) { - yield await getAll(sql, parameters); + Stream watchInner(Iterable trigger) { + return onChange( + trigger, + throttle: throttle, + triggerImmediately: true, + ).asyncMap((_) => getAll(sql, parameters)); + } + + if (triggerOnTables case final knownTrigger?) { + return watchInner(knownTrigger); + } else { + return Stream.fromFuture(getSourceTables(this, sql, parameters)) + .asyncExpand(watchInner); } } @@ -109,7 +107,7 @@ mixin SqliteQueries implements SqliteWriteContext, SqliteConnection { Future Function(SqliteWriteContext tx) callback, {Duration? lockTimeout}) async { return writeLock((ctx) async { - return await internalWriteTransaction(ctx, callback); + return ctx.writeTransaction(callback); }, lockTimeout: lockTimeout, debugContext: 'writeTransaction()'); } @@ -137,4 +135,9 @@ mixin SqliteQueries implements SqliteWriteContext, SqliteConnection { return tx.executeBatch(sql, parameterSets); }); } + + @override + Future refreshSchema() { + return getAll("PRAGMA table_info('sqlite_master')"); + } } diff --git a/packages/sqlite_async/lib/src/update_notification.dart b/packages/sqlite_async/lib/src/update_notification.dart index 8141df9..21f3541 100644 --- a/packages/sqlite_async/lib/src/update_notification.dart +++ b/packages/sqlite_async/lib/src/update_notification.dart @@ -52,68 +52,138 @@ class UpdateNotification { static Stream throttleStream( Stream input, Duration timeout, {UpdateNotification? addOne}) { - return _throttleStream(input, timeout, addOne: addOne, throttleFirst: true, - add: (a, b) { - return a.union(b); - }); + return _throttleStream( + input: input, + timeout: timeout, + throttleFirst: true, + add: (a, b) => a.union(b), + addOne: addOne, + ); } /// Filter an update stream by specific tables. static StreamTransformer filterTablesTransformer(Iterable tables) { Set normalized = {for (var table in tables) table.toLowerCase()}; - return StreamTransformer.fromHandlers(handleData: (data, sink) { - if (data.containsAny(normalized)) { - sink.add(data); - } - }); + return StreamTransformer.fromBind( + (source) => source.where((data) => data.containsAny(normalized))); } } -/// Given a broadcast stream, return a singular throttled stream that is throttled. -/// This immediately starts listening. +/// Throttles an [input] stream to not emit events more often than with a +/// frequency of 1/[timeout]. +/// +/// When an event is received and no timeout window is active, it is forwarded +/// downstream and a timeout window is started. For events received within a +/// timeout window, [add] is called to fold events. Then when the window +/// expires, pending events are emitted. +/// The subscription to the [input] stream is never paused. +/// +/// When the returned stream is paused, an active timeout window is reset and +/// restarts after the stream is resumed. /// -/// Behaviour: -/// If there was no event in "timeout", and one comes in, it is pushed immediately. -/// Otherwise, we wait until the timeout is over. -Stream _throttleStream(Stream input, Duration timeout, - {bool throttleFirst = false, T Function(T, T)? add, T? addOne}) async* { - var nextPing = Completer(); - T? lastData; - - var listener = input.listen((data) { - if (lastData is T && add != null) { - lastData = add(lastData as T, data); - } else { - lastData = data; +/// If [addOne] is not null, that event will always be added when the stream is +/// subscribed to. +/// When [throttleFirst] is true, a timeout window begins immediately after +/// listening (so that the first event, apart from [addOne], is emitted no +/// earlier than after [timeout]). +Stream _throttleStream({ + required Stream input, + required Duration timeout, + required bool throttleFirst, + required T Function(T, T) add, + required T? addOne, +}) { + return Stream.multi((listener) { + T? pendingData; + Timer? activeTimeoutWindow; + var needsTimeoutWindowAfterResume = false; + + /// Add pending data, bypassing the active timeout window. + /// + /// This is used to forward error and done events immediately. + bool addPendingEvents() { + if (pendingData case final data?) { + pendingData = null; + listener.addSync(data); + activeTimeoutWindow?.cancel(); + activeTimeoutWindow = null; + return true; + } else { + return false; + } } - if (!nextPing.isCompleted) { - nextPing.complete(); + + late void Function() setTimeout; + + /// Emits [pendingData] if no timeout window is active, and then starts a + /// timeout window if necessary. + void maybeEmit() { + if (activeTimeoutWindow == null && !listener.isPaused) { + final didAdd = addPendingEvents(); + if (didAdd) { + // Schedule a pause after resume if the subscription was paused + // directly in response to receiving the event. Otherwise, begin the + // timeout window immediately. + if (listener.isPaused) { + needsTimeoutWindowAfterResume = true; + } else { + setTimeout(); + } + } + } } - }); - try { + setTimeout = () { + activeTimeoutWindow = Timer(timeout, () { + activeTimeoutWindow = null; + maybeEmit(); + }); + }; + + void onData(T data) { + pendingData = switch (pendingData) { + null => data, + final pending => add(pending, data), + }; + maybeEmit(); + } + + void onError(Object error, StackTrace trace) { + addPendingEvents(); + listener.addErrorSync(error, trace); + } + + void onDone() { + addPendingEvents(); + listener.closeSync(); + } + + final subscription = input.listen(onData, onError: onError, onDone: onDone); + + listener.onPause = () { + needsTimeoutWindowAfterResume = activeTimeoutWindow != null; + activeTimeoutWindow?.cancel(); + activeTimeoutWindow = null; + }; + listener.onResume = () { + if (needsTimeoutWindowAfterResume) { + setTimeout(); + } else { + maybeEmit(); + } + }; + listener.onCancel = () async { + activeTimeoutWindow?.cancel(); + return subscription.cancel(); + }; + if (addOne != null) { - yield addOne; + // This must not be sync, we're doing this directly in onListen + listener.add(addOne); } if (throttleFirst) { - await Future.delayed(timeout); - } - while (true) { - // If a value is available now, we'll use it immediately. - // If not, this waits for it. - await nextPing.future; - // Capture any new values coming in while we wait. - nextPing = Completer(); - T data = lastData as T; - // Clear before we yield, so that we capture new changes while yielding - lastData = null; - yield data; - // Wait a minimum of this duration between tasks - await Future.delayed(timeout); + setTimeout(); } - } finally { - listener.cancel(); - } + }); } diff --git a/packages/sqlite_async/lib/src/utils/profiler.dart b/packages/sqlite_async/lib/src/utils/profiler.dart new file mode 100644 index 0000000..bffbf39 --- /dev/null +++ b/packages/sqlite_async/lib/src/utils/profiler.dart @@ -0,0 +1,64 @@ +import 'dart:developer'; + +extension TimeSync on TimelineTask? { + T timeSync(String name, TimelineSyncFunction function, + {String? sql, List? parameters}) { + final currentTask = this; + if (currentTask == null) { + return function(); + } + + final (resolvedName, args) = + profilerNameAndArgs(name, sql: sql, parameters: parameters); + currentTask.start(resolvedName, arguments: args); + + try { + return function(); + } finally { + currentTask.finish(); + } + } + + Future timeAsync(String name, TimelineSyncFunction> function, + {String? sql, List? parameters}) { + final currentTask = this; + if (currentTask == null) { + return function(); + } + + final (resolvedName, args) = + profilerNameAndArgs(name, sql: sql, parameters: parameters); + currentTask.start(resolvedName, arguments: args); + + return Future.sync(function).whenComplete(() { + currentTask.finish(); + }); + } +} + +(String, Map) profilerNameAndArgs(String name, + {String? sql, List? parameters}) { + // On native platforms, we want static names for tasks because every + // unique key here shows up in a separate line in Perfetto: https://github.com/dart-lang/sdk/issues/56274 + // On the web however, the names are embedded in the timeline slices and + // it's convenient to include the SQL there. + const isWeb = bool.fromEnvironment('dart.library.js_interop'); + var resolvedName = '$profilerPrefix$name'; + if (isWeb && sql != null) { + resolvedName = '$resolvedName $sql'; + } + + return ( + resolvedName, + { + if (sql != null) 'sql': sql, + if (parameters != null) + 'parameters': [ + for (final parameter in parameters) + if (parameter is List) '' else parameter + ], + } + ); +} + +const profilerPrefix = 'sqlite_async:'; diff --git a/packages/sqlite_async/lib/src/utils/shared_utils.dart b/packages/sqlite_async/lib/src/utils/shared_utils.dart index c911bbc..542611e 100644 --- a/packages/sqlite_async/lib/src/utils/shared_utils.dart +++ b/packages/sqlite_async/lib/src/utils/shared_utils.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:sqlite3/common.dart'; + import '../sqlite_connection.dart'; Future internalReadTransaction(SqliteReadContext ctx, @@ -21,24 +23,6 @@ Future internalReadTransaction(SqliteReadContext ctx, } } -Future internalWriteTransaction(SqliteWriteContext ctx, - Future Function(SqliteWriteContext tx) callback) async { - try { - await ctx.execute('BEGIN IMMEDIATE'); - final result = await callback(ctx); - await ctx.execute('COMMIT'); - return result; - } catch (e) { - try { - await ctx.execute('ROLLBACK'); - } catch (e) { - // In rare cases, a ROLLBACK may fail. - // Safe to ignore. - } - rethrow; - } -} - /// Given a SELECT query, return the tables that the query depends on. Future> getSourceTablesText( SqliteReadContext ctx, String sql) async { @@ -93,3 +77,87 @@ Object? mapParameter(Object? parameter) { List mapParameters(List parameters) { return [for (var p in parameters) mapParameter(p)]; } + +extension ThrottledUpdates on CommonDatabase { + /// An unthrottled stream of updated tables that emits on every commit. + /// + /// A paused subscription on this stream will buffer changed tables into a + /// growing set instead of losing events, so this stream is simple to throttle + /// downstream. + Stream> get updatedTables { + final listeners = <_UpdateListener>[]; + var uncommitedUpdates = {}; + var underlyingSubscriptions = >[]; + + void handleUpdate(SqliteUpdate update) { + uncommitedUpdates.add(update.tableName); + } + + void afterCommit() { + for (final listener in listeners) { + listener.notify(uncommitedUpdates); + } + + uncommitedUpdates.clear(); + } + + void afterRollback() { + uncommitedUpdates.clear(); + } + + void addListener(_UpdateListener listener) { + listeners.add(listener); + + if (listeners.length == 1) { + // First listener, start listening for raw updates on underlying + // database. + underlyingSubscriptions = [ + updatesSync.listen(handleUpdate), + commits.listen((_) => afterCommit()), + commits.listen((_) => afterRollback()) + ]; + } + } + + void removeListener(_UpdateListener listener) { + listeners.remove(listener); + if (listeners.isEmpty) { + for (final sub in underlyingSubscriptions) { + sub.cancel(); + } + } + } + + return Stream.multi( + (listener) { + final wrapped = _UpdateListener(listener); + addListener(wrapped); + + listener.onResume = wrapped.addPending; + listener.onCancel = () => removeListener(wrapped); + }, + isBroadcast: true, + ); + } +} + +class _UpdateListener { + final MultiStreamController> downstream; + Set buffered = {}; + + _UpdateListener(this.downstream); + + void notify(Set pendingUpdates) { + buffered.addAll(pendingUpdates); + if (!downstream.isPaused) { + addPending(); + } + } + + void addPending() { + if (buffered.isNotEmpty) { + downstream.add(buffered); + buffered = {}; + } + } +} diff --git a/packages/sqlite_async/lib/src/web/database.dart b/packages/sqlite_async/lib/src/web/database.dart index d7b78bf..f2dc998 100644 --- a/packages/sqlite_async/lib/src/web/database.dart +++ b/packages/sqlite_async/lib/src/web/database.dart @@ -1,22 +1,43 @@ import 'dart:async'; +import 'dart:developer'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:sqlite3/common.dart'; import 'package:sqlite3_web/sqlite3_web.dart'; +import 'package:sqlite3_web/protocol_utils.dart' as proto; import 'package:sqlite_async/sqlite_async.dart'; -import 'package:sqlite_async/src/utils/shared_utils.dart'; +import 'package:sqlite_async/src/utils/profiler.dart'; +import 'package:sqlite_async/src/web/database/broadcast_updates.dart'; +import 'package:sqlite_async/web.dart'; +import '../impl/context.dart'; import 'protocol.dart'; +import 'web_mutex.dart'; class WebDatabase with SqliteQueries, SqliteDatabaseMixin - implements SqliteDatabase { + implements SqliteDatabase, WebSqliteConnection { final Database _database; final Mutex? _mutex; + final bool profileQueries; + + @override + final Stream updates; + + /// For persistent databases that aren't backed by a shared worker, we use + /// web broadcast channels to forward local update events to other tabs. + final BroadcastUpdates? broadcastUpdates; @override bool closed = false; - WebDatabase(this._database, this._mutex); + WebDatabase( + this._database, + this._mutex, { + required this.profileQueries, + required this.updates, + this.broadcastUpdates, + }); @override Future close() async { @@ -24,6 +45,9 @@ class WebDatabase closed = true; } + @override + Future get closedFuture => _database.closed; + @override Future getAutoCommit() async { final response = await _database.customRequest( @@ -56,17 +80,27 @@ class WebDatabase /// Not relevant for web. Never get openFactory => throw UnimplementedError(); + @override + Future exposeEndpoint() async { + final endpoint = await _database.additionalConnection(); + + return ( + connectPort: endpoint.$1, + connectName: endpoint.$2, + lockName: switch (_mutex) { + MutexImpl(:final resolvedIdentifier) => resolvedIdentifier, + _ => null, + }, + ); + } + @override Future readLock(Future Function(SqliteReadContext tx) callback, {Duration? lockTimeout, String? debugContext}) async { if (_mutex case var mutex?) { - return await mutex.lock(() async { - final context = _SharedContext(this); - try { - return await callback(context); - } finally { - context.markClosed(); - } + return await mutex.lock(timeout: lockTimeout, () { + return ScopedReadContext.assumeReadLock( + _UnscopedContext(this), callback); }); } else { // No custom mutex, coordinate locks through shared worker. @@ -74,7 +108,8 @@ class WebDatabase CustomDatabaseMessage(CustomDatabaseMessageKind.requestSharedLock)); try { - return await callback(_SharedContext(this)); + return await ScopedReadContext.assumeReadLock( + _UnscopedContext(this), callback); } finally { await _database.customRequest( CustomDatabaseMessage(CustomDatabaseMessageKind.releaseLock)); @@ -82,63 +117,80 @@ class WebDatabase } } - @override - Stream get updates => - _database.updates.map((event) => UpdateNotification({event.tableName})); - @override Future writeTransaction( Future Function(SqliteWriteContext tx) callback, - {Duration? lockTimeout}) { - return writeLock( - (writeContext) => - internalWriteTransaction(writeContext, (context) async { - // All execute calls done in the callback will be checked for the - // autocommit state - return callback(_ExclusiveTransactionContext(this, writeContext)); - }), + {Duration? lockTimeout, + bool? flush}) { + return writeLock((writeContext) { + return ScopedWriteContext.assumeWriteLock( + _UnscopedContext(this), + (ctx) async { + return await ctx.writeTransaction(callback); + }, + ); + }, debugContext: 'writeTransaction()', - lockTimeout: lockTimeout); + lockTimeout: lockTimeout, + flush: flush); } @override - - /// Internal writeLock which intercepts transaction context's to verify auto commit is not active Future writeLock(Future Function(SqliteWriteContext tx) callback, - {Duration? lockTimeout, String? debugContext}) async { + {Duration? lockTimeout, String? debugContext, bool? flush}) async { if (_mutex case var mutex?) { - return await mutex.lock(() async { - final context = _ExclusiveContext(this); + return await mutex.lock(timeout: lockTimeout, () async { + final context = _UnscopedContext(this); try { - return await callback(context); + return await ScopedWriteContext.assumeWriteLock(context, callback); } finally { - context.markClosed(); + if (flush != false) { + await this.flush(); + } } }); } else { // No custom mutex, coordinate locks through shared worker. await _database.customRequest(CustomDatabaseMessage( CustomDatabaseMessageKind.requestExclusiveLock)); - final context = _ExclusiveContext(this); + final context = _UnscopedContext(this); try { - return await callback(context); + return await ScopedWriteContext.assumeWriteLock(context, callback); } finally { - context.markClosed(); + if (flush != false) { + await this.flush(); + } await _database.customRequest( CustomDatabaseMessage(CustomDatabaseMessageKind.releaseLock)); } } } + + @override + Future flush() async { + await isInitialized; + return _database.fileSystem.flush(); + } + + @override + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) { + return writeLock((_) => block(this, [])); + } } -class _SharedContext implements SqliteReadContext { +final class _UnscopedContext extends UnscopedContext { final WebDatabase _database; - bool _contextClosed = false; - _SharedContext(this._database); + final TimelineTask? _task; + + _UnscopedContext(this._database) + : _task = _database.profileQueries ? TimelineTask() : null; @override - bool get closed => _contextClosed || _database.closed; + bool get closed => _database.closed; @override Future computeWithDatabase( @@ -150,14 +202,21 @@ class _SharedContext implements SqliteReadContext { @override Future get(String sql, [List parameters = const []]) async { final results = await getAll(sql, parameters); - return results.single; + return results.first; } @override Future getAll(String sql, [List parameters = const []]) async { - return await wrapSqliteException( - () => _database._database.select(sql, parameters)); + return _task.timeAsync( + 'getAll', + sql: sql, + parameters: parameters, + () async { + return await wrapSqliteException( + () => _database._database.select(sql, parameters)); + }, + ); } @override @@ -169,47 +228,43 @@ class _SharedContext implements SqliteReadContext { Future getOptional(String sql, [List parameters = const []]) async { final results = await getAll(sql, parameters); - return results.singleOrNull; + return results.firstOrNull; } - void markClosed() { - _contextClosed = true; + @override + Future execute(String sql, [List parameters = const []]) { + return _task.timeAsync('execute', sql: sql, parameters: parameters, () { + return wrapSqliteException( + () => _database._database.select(sql, parameters)); + }); } -} - -class _ExclusiveContext extends _SharedContext implements SqliteWriteContext { - _ExclusiveContext(super.database); @override - Future execute(String sql, - [List parameters = const []]) async { - return wrapSqliteException( - () => _database._database.select(sql, parameters)); + Future executeBatch(String sql, List> parameterSets) { + return _task.timeAsync('executeBatch', sql: sql, () { + return wrapSqliteException(() async { + for (final set in parameterSets) { + // use execute instead of select to avoid transferring rows from the + // worker to this context. + await _database._database.execute(sql, set); + } + }); + }); } @override - Future executeBatch( - String sql, List> parameterSets) async { - return wrapSqliteException(() async { - for (final set in parameterSets) { - // use execute instead of select to avoid transferring rows from the - // worker to this context. - await _database._database.execute(sql, set); - } - }); + UnscopedContext interceptOutermostTransaction() { + // All execute calls done in the callback will be checked for the + // autocommit state + return _ExclusiveTransactionContext(_database); } } -class _ExclusiveTransactionContext extends _ExclusiveContext { - SqliteWriteContext baseContext; - _ExclusiveTransactionContext(super.database, this.baseContext); +final class _ExclusiveTransactionContext extends _UnscopedContext { + _ExclusiveTransactionContext(super._database); - @override - bool get closed => baseContext.closed; - - @override - Future execute(String sql, - [List parameters = const []]) async { + Future _executeInternal( + String sql, List parameters) async { // Operations inside transactions are executed with custom requests // in order to verify that the connection does not have autocommit enabled. // The worker will check if autocommit = true before executing the SQL. @@ -218,9 +273,15 @@ class _ExclusiveTransactionContext extends _ExclusiveContext { // JavaScript object. This is the converted into a Dart ResultSet. return await wrapSqliteException(() async { var res = await _database._database.customRequest(CustomDatabaseMessage( - CustomDatabaseMessageKind.executeInTransaction, sql, parameters)); - var result = - Map.from((res as JSObject).dartify() as Map); + CustomDatabaseMessageKind.executeInTransaction, sql, parameters)) + as JSObject; + + if (res.has('format') && (res['format'] as JSNumber).toDartInt == 2) { + // Newer workers use a serialization format more efficient than dartify(). + return proto.deserializeResultSet(res['r'] as JSObject); + } + + var result = Map.from(res.dartify() as Map); final columnNames = [ for (final entry in result['columnNames']) entry as String ]; @@ -247,15 +308,22 @@ class _ExclusiveTransactionContext extends _ExclusiveContext { }); } + @override + Future execute(String sql, + [List parameters = const []]) async { + return _task.timeAsync('execute', sql: sql, parameters: parameters, () { + return _executeInternal(sql, parameters); + }); + } + @override Future executeBatch( String sql, List> parameterSets) async { - return await wrapSqliteException(() async { + return _task.timeAsync('executeBatch', sql: sql, () async { for (final set in parameterSets) { await _database._database.customRequest(CustomDatabaseMessage( CustomDatabaseMessageKind.executeBatchInTransaction, sql, set)); } - return; }); } } @@ -265,9 +333,14 @@ Future wrapSqliteException(Future Function() callback) async { try { return await callback(); } on RemoteException catch (ex) { + if (ex.exception case final serializedCause?) { + throw serializedCause; + } + + // Older versions of package:sqlite_web reported SqliteExceptions as strings + // only. if (ex.toString().contains('SqliteException')) { RegExp regExp = RegExp(r'SqliteException\((\d+)\)'); - // The SQLite Web package wraps these in remote errors throw SqliteException( int.parse(regExp.firstMatch(ex.message)?.group(1) ?? '0'), ex.message); diff --git a/packages/sqlite_async/lib/src/web/database/broadcast_updates.dart b/packages/sqlite_async/lib/src/web/database/broadcast_updates.dart new file mode 100644 index 0000000..5514e74 --- /dev/null +++ b/packages/sqlite_async/lib/src/web/database/broadcast_updates.dart @@ -0,0 +1,50 @@ +import 'dart:js_interop'; + +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:web/web.dart' as web; + +/// Utility to share received [UpdateNotification]s with other tabs using +/// broadcast channels. +class BroadcastUpdates { + final web.BroadcastChannel _channel; + + BroadcastUpdates(String name) + : _channel = web.BroadcastChannel('sqlite3_async_updates/$name'); + + Stream get updates { + return web.EventStreamProviders.messageEvent + .forTarget(_channel) + .map((event) { + final data = event.data as _BroadcastMessage; + if (data.a == 0) { + final payload = data.b as JSArray; + return UpdateNotification( + payload.toDart.map((e) => e.toDart).toSet()); + } else { + return null; + } + }) + .where((e) => e != null) + .cast(); + } + + void send(UpdateNotification notification) { + _channel.postMessage(_BroadcastMessage.notifications(notification)); + } +} + +@JS() +@anonymous +extension type _BroadcastMessage._(JSObject _) implements JSObject { + external int get a; + external JSAny get b; + + external factory _BroadcastMessage({required int a, required JSAny b}); + + factory _BroadcastMessage.notifications(UpdateNotification notification) { + return _BroadcastMessage( + a: 0, + b: notification.tables.map((e) => e.toJS).toList().toJS, + ); + } +} diff --git a/packages/sqlite_async/lib/src/web/database/web_sqlite_database.dart b/packages/sqlite_async/lib/src/web/database/web_sqlite_database.dart index 522b48e..69f01ab 100644 --- a/packages/sqlite_async/lib/src/web/database/web_sqlite_database.dart +++ b/packages/sqlite_async/lib/src/web/database/web_sqlite_database.dart @@ -8,19 +8,24 @@ import 'package:sqlite_async/src/common/sqlite_database.dart'; import 'package:sqlite_async/src/sqlite_connection.dart'; import 'package:sqlite_async/src/sqlite_options.dart'; import 'package:sqlite_async/src/update_notification.dart'; -import 'package:sqlite_async/src/web/web_mutex.dart'; import 'package:sqlite_async/src/web/web_sqlite_open_factory.dart'; +import 'package:sqlite_async/web.dart'; + +import '../database.dart'; /// Web implementation of [SqliteDatabase] /// Uses a web worker for SQLite connection class SqliteDatabaseImpl with SqliteQueries, SqliteDatabaseMixin - implements SqliteDatabase { + implements SqliteDatabase, WebSqliteConnection { @override bool get closed { return _connection.closed; } + @override + Future get closedFuture => _connection.closedFuture; + final StreamController updatesController = StreamController.broadcast(); @@ -37,8 +42,8 @@ class SqliteDatabaseImpl @override AbstractDefaultSqliteOpenFactory openFactory; - late final Mutex mutex; - late final SqliteConnection _connection; + late final WebDatabase _connection; + StreamSubscription? _broadcastUpdatesSubscription; /// Open a SqliteDatabase. /// @@ -70,18 +75,37 @@ class SqliteDatabaseImpl /// 4. Creating temporary views or triggers. SqliteDatabaseImpl.withFactory(this.openFactory, {this.maxReaders = SqliteDatabase.defaultMaxReaders}) { - mutex = MutexImpl(); // This way the `updates` member is available synchronously updates = updatesController.stream; isInitialized = _init(); } Future _init() async { - _connection = await openFactory.openConnection(SqliteOpenOptions( - primaryConnection: true, readOnly: false, mutex: mutex)); - _connection.updates!.forEach((update) { - updatesController.add(update); - }); + _connection = await openFactory.openConnection( + SqliteOpenOptions(primaryConnection: true, readOnly: false)) + as WebDatabase; + + final broadcastUpdates = _connection.broadcastUpdates; + if (broadcastUpdates == null) { + // We can use updates directly from the database. + _connection.updates.forEach((update) { + updatesController.add(update); + }); + } else { + _connection.updates.forEach((update) { + updatesController.add(update); + + // Share local updates with other tabs + broadcastUpdates.send(update); + }); + + // Also add updates from other tabs, note that things we send aren't + // received by our tab. + _broadcastUpdatesSubscription = + broadcastUpdates.updates.listen((updates) { + updatesController.add(updates); + }); + } } T _runZoned(T Function() callback, {required String debugContext}) { @@ -105,27 +129,37 @@ class SqliteDatabaseImpl @override Future writeLock(Future Function(SqliteWriteContext tx) callback, - {Duration? lockTimeout, String? debugContext}) async { + {Duration? lockTimeout, String? debugContext, bool? flush}) async { await isInitialized; return _runZoned(() { return _connection.writeLock(callback, - lockTimeout: lockTimeout, debugContext: debugContext); + lockTimeout: lockTimeout, debugContext: debugContext, flush: flush); }, debugContext: debugContext ?? 'execute()'); } @override Future writeTransaction( Future Function(SqliteWriteContext tx) callback, - {Duration? lockTimeout}) async { + {Duration? lockTimeout, + bool? flush}) async { await isInitialized; return _runZoned( - () => _connection.writeTransaction(callback, lockTimeout: lockTimeout), + () => _connection.writeTransaction(callback, + lockTimeout: lockTimeout, flush: flush), debugContext: 'writeTransaction()'); } + @override + Future flush() async { + await isInitialized; + return _connection.flush(); + } + @override Future close() async { await isInitialized; + _broadcastUpdatesSubscription?.cancel(); + updatesController.close(); return _connection.close(); } @@ -139,4 +173,17 @@ class SqliteDatabaseImpl await isInitialized; return _connection.getAutoCommit(); } + + @override + Future exposeEndpoint() async { + return await _connection.exposeEndpoint(); + } + + @override + Future withAllConnections( + Future Function( + SqliteWriteContext writer, List readers) + block) { + return writeLock((_) => block(_connection, [])); + } } diff --git a/packages/sqlite_async/lib/src/web/protocol.dart b/packages/sqlite_async/lib/src/web/protocol.dart index e6206d6..d17c06b 100644 --- a/packages/sqlite_async/lib/src/web/protocol.dart +++ b/packages/sqlite_async/lib/src/web/protocol.dart @@ -3,6 +3,7 @@ library; import 'dart:js_interop'; +import 'package:sqlite3_web/protocol_utils.dart' as proto; enum CustomDatabaseMessageKind { requestSharedLock, @@ -12,6 +13,8 @@ enum CustomDatabaseMessageKind { getAutoCommit, executeInTransaction, executeBatchInTransaction, + updateSubscriptionManagement, + notifyUpdates, } extension type CustomDatabaseMessage._raw(JSObject _) implements JSObject { @@ -19,15 +22,24 @@ extension type CustomDatabaseMessage._raw(JSObject _) implements JSObject { required JSString rawKind, JSString rawSql, JSArray rawParameters, + JSArrayBuffer typeInfo, }); factory CustomDatabaseMessage(CustomDatabaseMessageKind kind, [String? sql, List parameters = const []]) { - final rawSql = sql?.toJS ?? ''.toJS; - final rawParameters = - [for (final parameter in parameters) parameter.jsify()].toJS; + final rawSql = (sql ?? '').toJS; + // Serializing parameters this way is backwards-compatible with dartify() + // on the other end, but a bit more efficient while also suppporting sound + // communcation between dart2js workers and dart2wasm clients. + // Older workers ignore the typeInfo, but that's not a problem. + final (rawParameters, typeInfo) = proto.serializeParameters(parameters); + return CustomDatabaseMessage._( - rawKind: kind.name.toJS, rawSql: rawSql, rawParameters: rawParameters); + rawKind: kind.name.toJS, + rawSql: rawSql, + rawParameters: rawParameters, + typeInfo: typeInfo, + ); } external JSString get rawKind; @@ -36,6 +48,9 @@ extension type CustomDatabaseMessage._raw(JSObject _) implements JSObject { external JSArray get rawParameters; + /// Not set in earlier versions of this package. + external JSArrayBuffer? get typeInfo; + CustomDatabaseMessageKind get kind { return CustomDatabaseMessageKind.values.byName(rawKind.toDart); } diff --git a/packages/sqlite_async/lib/src/web/update_notifications.dart b/packages/sqlite_async/lib/src/web/update_notifications.dart new file mode 100644 index 0000000..f04a785 --- /dev/null +++ b/packages/sqlite_async/lib/src/web/update_notifications.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:sqlite3_web/sqlite3_web.dart'; + +import '../update_notification.dart'; +import 'protocol.dart'; + +/// Utility to request a stream of update notifications from the worker. +/// +/// Because we want to debounce update notifications on the worker, we're using +/// custom requests instead of the default [Database.updates] stream. +/// +/// Clients send a message to the worker to subscribe or unsubscribe, providing +/// an id for the subscription. The worker distributes update notifications with +/// custom requests to the client, which [handleRequest] distributes to the +/// original streams. +final class UpdateNotificationStreams { + var _idCounter = 0; + final Map> _updates = {}; + + Future handleRequest(JSAny? request) async { + final customRequest = request as CustomDatabaseMessage; + if (customRequest.kind == CustomDatabaseMessageKind.notifyUpdates) { + final notification = UpdateNotification(customRequest.rawParameters.toDart + .map((e) => (e as JSString).toDart) + .toSet()); + + final controller = _updates[customRequest.rawSql.toDart]; + controller?.add(notification); + } + + return null; + } + + Stream updatesFor(Database database) { + final id = (_idCounter++).toString(); + final controller = _updates[id] = StreamController(); + + controller + ..onListen = () { + database.customRequest(CustomDatabaseMessage( + CustomDatabaseMessageKind.updateSubscriptionManagement, + id, + [true], + )); + } + ..onCancel = () { + database.customRequest(CustomDatabaseMessage( + CustomDatabaseMessageKind.updateSubscriptionManagement, + id, + [false], + )); + + _updates.remove(id); + }; + + return controller.stream; + } +} diff --git a/packages/sqlite_async/lib/src/web/web_mutex.dart b/packages/sqlite_async/lib/src/web/web_mutex.dart index b5722c5..6972b2f 100644 --- a/packages/sqlite_async/lib/src/web/web_mutex.dart +++ b/packages/sqlite_async/lib/src/web/web_mutex.dart @@ -1,13 +1,35 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:meta/meta.dart'; import 'package:mutex/mutex.dart' as mutex; +import 'dart:js_interop'; +// This allows for checking things like hasProperty without the need for depending on the `js` package +import 'dart:js_interop_unsafe'; +import 'package:web/web.dart'; + import 'package:sqlite_async/src/common/mutex.dart'; +@JS('navigator') +external Navigator get _navigator; + /// Web implementation of [Mutex] -/// This should use `navigator.locks` in future class MutexImpl implements Mutex { - late final mutex.Mutex m; + late final mutex.Mutex fallback; + final String resolvedIdentifier; - MutexImpl() { - m = mutex.Mutex(); + MutexImpl({String? identifier}) + + /// On web a lock name is required for Navigator locks. + /// Having exclusive Mutex instances requires a somewhat unique lock name. + /// This provides a best effort unique identifier, if no identifier is provided. + /// This should be fine for most use cases: + /// - The uuid package could be added for better uniqueness if required. + /// This would add another package dependency to `sqlite_async` which is potentially unnecessary at this point. + /// An identifier should be supplied for better exclusion. + : resolvedIdentifier = identifier ?? + "${DateTime.now().microsecondsSinceEpoch}-${Random().nextDouble()}" { + fallback = mutex.Mutex(); } @override @@ -17,8 +39,95 @@ class MutexImpl implements Mutex { @override Future lock(Future Function() callback, {Duration? timeout}) { - // Note this lock is only valid in a single web tab - return m.protect(callback); + if (_navigator.has('locks')) { + return _webLock(callback, timeout: timeout); + } else { + return _fallbackLock(callback, timeout: timeout); + } + } + + /// Locks the callback with a standard Mutex from the `mutex` package + Future _fallbackLock(Future Function() callback, + {Duration? timeout}) { + final completer = Completer(); + // Need to implement timeout manually for this + bool isTimedOut = false; + Timer? timer; + if (timeout != null) { + timer = Timer(timeout, () { + isTimedOut = true; + completer + .completeError(TimeoutException('Failed to acquire lock', timeout)); + }); + } + + fallback.protect(() async { + try { + if (isTimedOut) { + // Don't actually run logic + return; + } + timer?.cancel(); + final result = await callback(); + completer.complete(result); + } catch (ex) { + completer.completeError(ex); + } + }); + + return completer.future; + } + + /// Locks the callback with web Navigator locks + Future _webLock(Future Function() callback, + {Duration? timeout}) async { + final lock = await _getWebLock(timeout); + try { + final result = await callback(); + return result; + } finally { + lock.release(); + } + } + + /// Passing the Dart callback directly to the JS Navigator can cause some weird + /// context related bugs. Instead the JS lock callback will return a hold on the lock + /// which is represented as a [HeldLock]. This hold can be used when wrapping the Dart + /// callback to manage the JS lock. + /// This is inspired and adapted from https://github.com/simolus3/sqlite3.dart/blob/7bdca77afd7be7159dbef70fd1ac5aa4996211a9/sqlite3_web/lib/src/locks.dart#L6 + Future _getWebLock(Duration? timeout) { + final gotLock = Completer.sync(); + // Navigator locks can be timed out by using an AbortSignal + final controller = AbortController(); + + Timer? timer; + + if (timeout != null) { + timer = Timer(timeout, () { + gotLock + .completeError(TimeoutException('Failed to acquire lock', timeout)); + controller.abort('Timeout'.toJS); + }); + } + + // If timeout occurred before the lock is available, then this callback should not be called. + JSPromise jsCallback(JSAny lock) { + timer?.cancel(); + + // Give the Held lock something to mark this Navigator lock as completed + final jsCompleter = Completer.sync(); + gotLock.complete(HeldLock._(jsCompleter)); + return jsCompleter.future.toJS; + } + + final lockOptions = JSObject(); + lockOptions['signal'] = controller.signal; + final promise = _navigator.locks + .request(resolvedIdentifier, lockOptions, jsCallback.toJS); + // A timeout abort will throw an exception which needs to be handled. + // There should not be any other unhandled lock errors. + promise.toDart.catchError((error) => null); + return gotLock.future; } @override @@ -26,3 +135,15 @@ class MutexImpl implements Mutex { return this; } } + +/// This represents a hold on an active Navigator lock. +/// This is created inside the Navigator lock callback function and is used to release the lock +/// from an external source. +@internal +class HeldLock { + final Completer _completer; + + HeldLock._(this._completer); + + void release() => _completer.complete(); +} diff --git a/packages/sqlite_async/lib/src/web/web_sqlite_open_factory.dart b/packages/sqlite_async/lib/src/web/web_sqlite_open_factory.dart index ce7e0c9..513b1f2 100644 --- a/packages/sqlite_async/lib/src/web/web_sqlite_open_factory.dart +++ b/packages/sqlite_async/lib/src/web/web_sqlite_open_factory.dart @@ -3,38 +3,51 @@ import 'dart:async'; import 'package:sqlite3/wasm.dart'; import 'package:sqlite3_web/sqlite3_web.dart'; import 'package:sqlite_async/sqlite_async.dart'; +import 'package:sqlite_async/src/web/database/broadcast_updates.dart'; import 'package:sqlite_async/src/web/web_mutex.dart'; +import 'package:sqlite_async/web.dart'; import 'database.dart'; +import 'worker/worker_utils.dart'; -Map> webSQLiteImplementations = {}; +Map> _webSQLiteImplementations = {}; /// Web implementation of [AbstractDefaultSqliteOpenFactory] class DefaultSqliteOpenFactory - extends AbstractDefaultSqliteOpenFactory { - final Future _initialized; + extends AbstractDefaultSqliteOpenFactory + with WebSqliteOpenFactory { + late final Future _initialized = Future.sync(() { + final cacheKey = sqliteOptions.webSqliteOptions.wasmUri + + sqliteOptions.webSqliteOptions.workerUri; - DefaultSqliteOpenFactory( - {required super.path, - super.sqliteOptions = const SqliteOptions.defaults()}) - : _initialized = Future.sync(() { - final cacheKey = sqliteOptions.webSqliteOptions.wasmUri + - sqliteOptions.webSqliteOptions.workerUri; + if (_webSQLiteImplementations.containsKey(cacheKey)) { + return _webSQLiteImplementations[cacheKey]!; + } - if (webSQLiteImplementations.containsKey(cacheKey)) { - return webSQLiteImplementations[cacheKey]!; - } + _webSQLiteImplementations[cacheKey] = + openWebSqlite(sqliteOptions.webSqliteOptions); + return _webSQLiteImplementations[cacheKey]!; + }); - webSQLiteImplementations[cacheKey] = WebSqlite.open( - wasmModule: Uri.parse(sqliteOptions.webSqliteOptions.wasmUri), - worker: Uri.parse(sqliteOptions.webSqliteOptions.workerUri), - ); - return webSQLiteImplementations[cacheKey]!; - }); + DefaultSqliteOpenFactory( + {required super.path, + super.sqliteOptions = const SqliteOptions.defaults()}) { + // Make sure initializer starts running immediately + _initialized; + } @override + Future openWebSqlite(WebSqliteOptions options) async { + return WebSqlite.open( + wasmModule: Uri.parse(sqliteOptions.webSqliteOptions.wasmUri), + worker: Uri.parse(sqliteOptions.webSqliteOptions.workerUri), + controller: AsyncSqliteController(), + handleCustomRequest: handleCustomRequest, + ); + } /// This is currently not supported on web + @override CommonDatabase openDB(SqliteOpenOptions options) { throw UnimplementedError( 'Direct access to CommonDatabase is not available on web.'); @@ -48,16 +61,28 @@ class DefaultSqliteOpenFactory /// Due to being asynchronous, the under laying CommonDatabase is not accessible Future openConnection(SqliteOpenOptions options) async { final workers = await _initialized; - final connection = await workers.connectToRecommended(path); + final connection = await connectToWorker(workers, path); // When the database is accessed through a shared worker, we implement // mutexes over custom messages sent through the shared worker. In other // cases, we need to implement a mutex locally. final mutex = connection.access == AccessMode.throughSharedWorker ? null - : MutexImpl(); + : MutexImpl(identifier: path); // Use the DB path as a mutex identifier + + BroadcastUpdates? broadcastUpdates; + if (connection.access != AccessMode.throughSharedWorker && + connection.storage != StorageMode.inMemory) { + broadcastUpdates = BroadcastUpdates(path); + } - return WebDatabase(connection.database, options.mutex ?? mutex); + return WebDatabase( + connection.database, + options.mutex ?? mutex, + broadcastUpdates: broadcastUpdates, + profileQueries: sqliteOptions.profileQueries, + updates: updatesFor(connection.database), + ); } @override diff --git a/packages/sqlite_async/lib/src/web/worker/worker_utils.dart b/packages/sqlite_async/lib/src/web/worker/worker_utils.dart index b4657dd..059c281 100644 --- a/packages/sqlite_async/lib/src/web/worker/worker_utils.dart +++ b/packages/sqlite_async/lib/src/web/worker/worker_utils.dart @@ -1,9 +1,13 @@ +import 'dart:async'; import 'dart:js_interop'; -import 'dart:js_util' as js_util; +import 'dart:js_interop_unsafe'; +import 'package:meta/meta.dart'; import 'package:mutex/mutex.dart'; import 'package:sqlite3/wasm.dart'; import 'package:sqlite3_web/sqlite3_web.dart'; +import 'package:sqlite3_web/protocol_utils.dart' as proto; +import 'package:sqlite_async/src/utils/shared_utils.dart'; import '../protocol.dart'; @@ -12,15 +16,25 @@ import '../protocol.dart'; /// can be extended to perform custom requests. base class AsyncSqliteController extends DatabaseController { @override - Future openDatabase( - WasmSqlite3 sqlite3, String path, String vfs) async { - final db = sqlite3.open(path, vfs: vfs); + Future openDatabase(WasmSqlite3 sqlite3, String path, + String vfs, JSAny? additionalData) async { + final db = openUnderlying(sqlite3, path, vfs, additionalData); // Register any custom functions here if needed return AsyncSqliteDatabase(database: db); } + @visibleForOverriding + CommonDatabase openUnderlying( + WasmSqlite3 sqlite3, + String path, + String vfs, + JSAny? additionalData, + ) { + return sqlite3.open(path, vfs: vfs); + } + @override Future handleCustomRequest( ClientConnection connection, JSAny? request) { @@ -33,13 +47,39 @@ base class AsyncSqliteController extends DatabaseController { class AsyncSqliteDatabase extends WorkerDatabase { @override final CommonDatabase database; + final Stream> _updates; // This mutex is only used for lock requests from clients. Clients only send // these requests for shared workers, so we can assume each database is only // opened once and we don't need web locks here. final mutex = ReadWriteMutex(); + final Map _state = {}; + + AsyncSqliteDatabase({required this.database}) + : _updates = database.updatedTables; + + _ConnectionState _findState(ClientConnection connection) { + return _state.putIfAbsent(connection, _ConnectionState.new); + } - AsyncSqliteDatabase({required this.database}); + void _markHoldsMutex(ClientConnection connection) { + final state = _findState(connection); + state.holdsMutex = true; + _registerCloseListener(state, connection); + } + + void _registerCloseListener( + _ConnectionState state, ClientConnection connection) { + if (!state.hasOnCloseListener) { + state.hasOnCloseListener = true; + connection.closed.then((_) { + state.unsubscribeUpdates(); + if (state.holdsMutex) { + mutex.release(); + } + }); + } + } @override Future handleCustomRequest( @@ -49,40 +89,72 @@ class AsyncSqliteDatabase extends WorkerDatabase { switch (message.kind) { case CustomDatabaseMessageKind.requestSharedLock: await mutex.acquireRead(); + _markHoldsMutex(connection); case CustomDatabaseMessageKind.requestExclusiveLock: await mutex.acquireWrite(); + _markHoldsMutex(connection); case CustomDatabaseMessageKind.releaseLock: + _findState(connection).holdsMutex = false; mutex.release(); case CustomDatabaseMessageKind.lockObtained: + case CustomDatabaseMessageKind.notifyUpdates: throw UnsupportedError('This is a response, not a request'); case CustomDatabaseMessageKind.getAutoCommit: return database.autocommit.toJS; case CustomDatabaseMessageKind.executeInTransaction: final sql = message.rawSql.toDart; - final parameters = [ - for (final raw in (message.rawParameters).toDart) raw.dartify() - ]; + final hasTypeInfo = message.typeInfo.isDefinedAndNotNull; + final parameters = proto.deserializeParameters( + message.rawParameters, message.typeInfo); if (database.autocommit) { throw SqliteException(0, "Transaction rolled back by earlier statement. Cannot execute: $sql"); } - var res = database.select(sql, parameters); - - var dartMap = resultSetToMap(res); - var jsObject = js_util.jsify(dartMap); + var res = database.select(sql, parameters); + if (hasTypeInfo) { + // If the client is sending a request that has parameters with type + // information, it will also support a newer serialization format for + // result sets. + return JSObject() + ..['format'] = 2.toJS + ..['r'] = proto.serializeResultSet(res); + } else { + var dartMap = resultSetToMap(res); + var jsObject = dartMap.jsify(); + return jsObject; + } - return jsObject; case CustomDatabaseMessageKind.executeBatchInTransaction: final sql = message.rawSql.toDart; - final parameters = [ - for (final raw in (message.rawParameters).toDart) raw.dartify() - ]; + final parameters = proto.deserializeParameters( + message.rawParameters, message.typeInfo); if (database.autocommit) { throw SqliteException(0, "Transaction rolled back by earlier statement. Cannot execute: $sql"); } database.execute(sql, parameters); + case CustomDatabaseMessageKind.updateSubscriptionManagement: + final shouldSubscribe = + (message.rawParameters.toDart[0] as JSBoolean).toDart; + final id = message.rawSql.toDart; + final state = _findState(connection); + + if (shouldSubscribe) { + state.unsubscribeUpdates(); + _registerCloseListener(state, connection); + + late StreamSubscription subscription; + subscription = state.updatesNotification = _updates.listen((tables) { + subscription.pause(connection.customRequest(CustomDatabaseMessage( + CustomDatabaseMessageKind.notifyUpdates, + id, + tables.toList(), + ))); + }); + } else { + state.unsubscribeUpdates(); + } } return CustomDatabaseMessage(CustomDatabaseMessageKind.lockObtained); @@ -98,3 +170,16 @@ class AsyncSqliteDatabase extends WorkerDatabase { return resultSetMap; } } + +final class _ConnectionState { + bool hasOnCloseListener = false; + bool holdsMutex = false; + StreamSubscription>? updatesNotification; + + void unsubscribeUpdates() { + if (updatesNotification case final active?) { + updatesNotification = null; + active.cancel(); + } + } +} diff --git a/packages/sqlite_async/lib/web.dart b/packages/sqlite_async/lib/web.dart new file mode 100644 index 0000000..e318697 --- /dev/null +++ b/packages/sqlite_async/lib/web.dart @@ -0,0 +1,156 @@ +/// Exposes interfaces implemented by database implementations on the web. +/// +/// These expose methods allowing database instances to be shared across web +/// workers. +library; + +import 'dart:js_interop'; + +import 'package:sqlite3_web/sqlite3_web.dart'; +import 'package:web/web.dart'; + +import 'sqlite3_common.dart'; +import 'sqlite_async.dart'; +import 'src/web/database.dart'; +import 'src/web/update_notifications.dart'; + +/// An endpoint that can be used, by any running JavaScript context in the same +/// website, to connect to an existing [WebSqliteConnection]. +/// +/// These endpoints are created by calling [WebSqliteConnection.exposeEndpoint] +/// and consist of a [MessagePort] and two [String]s internally identifying the +/// connection. Both objects can be transferred over send ports towards another +/// worker or context. That context can then use +/// [WebSqliteConnection.connectToEndpoint] to connect to the port already +/// opened. +typedef WebDatabaseEndpoint = ({ + MessagePort connectPort, + String connectName, + String? lockName, +}); + +final UpdateNotificationStreams _updateStreams = UpdateNotificationStreams(); + +/// An additional interface for [SqliteOpenFactory] exposing additional +/// functionality that is only relevant when compiling to the web. +/// +/// The [DefaultSqliteOpenFactory] class implements this interface only when +/// compiling for the web. +abstract mixin class WebSqliteOpenFactory + implements SqliteOpenFactory { + /// Handles a custom request sent from the worker to the client. + Future handleCustomRequest(JSAny? request) { + return _updateStreams.handleRequest(request); + } + + /// Opens a [WebSqlite] instance for the given [options]. + /// + /// This method can be overriden in scenarios where the way [WebSqlite] is + /// opened needs to be customized. Implementers should be aware that the + /// result of this method is cached and will be re-used by the open factory + /// when provided with the same [options] again. + Future openWebSqlite(WebSqliteOptions options) async { + return WebSqlite.open( + worker: Uri.parse(options.workerUri), + wasmModule: Uri.parse(options.wasmUri), + handleCustomRequest: handleCustomRequest, + ); + } + + /// Uses [WebSqlite] to connects to the recommended database setup for [name]. + /// + /// This typically just calls [WebSqlite.connectToRecommended], but subclasses + /// can customize the behavior where needed. + Future connectToWorker( + WebSqlite sqlite, String name) { + return sqlite.connectToRecommended(name); + } + + /// Obtains a stream of [UpdateNotification]s from a [database]. + /// + /// The default implementation uses custom requests to allow workers to + /// debounce the stream on their side to avoid messages where possible. + Stream updatesFor(Database database) { + return _updateStreams.updatesFor(database); + } +} + +/// A [SqliteConnection] interface implemented by opened connections when +/// running on the web. +/// +/// This adds the [exposeEndpoint], which uses `dart:js_interop` types not +/// supported on native Dart platforms. The method can be used to access an +/// opened database across different JavaScript contexts +/// (e.g. document windows and workers). +abstract class WebSqliteConnection implements SqliteConnection { + /// Returns a future that completes when this connection is closed. + /// + /// This usually only happens when calling [close], but on the web + /// specifically, it can also happen when a remote context closes a database + /// accessed via [connectToEndpoint]. + Future get closedFuture; + + /// Returns a [WebDatabaseEndpoint] - a structure that consists only of types + /// that can be transferred across a [MessagePort] in JavaScript. + /// + /// After transferring this endpoint to another JavaScript context (e.g. a + /// worker), the worker can call [connectToEndpoint] to obtain a connection to + /// the same sqlite database. + Future exposeEndpoint(); + + /// Connect to an endpoint obtained through [exposeEndpoint]. + /// + /// The endpoint is transferrable in JavaScript, allowing multiple JavaScript + /// contexts to exchange opened database connections. + static Future connectToEndpoint( + WebDatabaseEndpoint endpoint) async { + final updates = UpdateNotificationStreams(); + final rawSqlite = await WebSqlite.connectToPort( + (endpoint.connectPort, endpoint.connectName), + handleCustomRequest: updates.handleRequest, + ); + + final database = WebDatabase( + rawSqlite, + switch (endpoint.lockName) { + var lock? => Mutex(identifier: lock), + null => null, + }, + profileQueries: false, + updates: updates.updatesFor(rawSqlite), + ); + return database; + } + + /// Same as [SqliteConnection.writeLock]. + /// + /// Has an additional [flush] (defaults to true). This can be set to false + /// to delay flushing changes to the database file, losing durability guarantees. + /// This only has an effect when IndexedDB storage is used. + /// + /// See [flush] for details. + @override + Future writeLock(Future Function(SqliteWriteContext tx) callback, + {Duration? lockTimeout, String? debugContext, bool? flush}); + + /// Same as [SqliteConnection.writeTransaction]. + /// + /// Has an additional [flush] (defaults to true). This can be set to false + /// to delay flushing changes to the database file, losing durability guarantees. + /// This only has an effect when IndexedDB storage is used. + /// + /// See [flush] for details. + @override + Future writeTransaction( + Future Function(SqliteWriteContext tx) callback, + {Duration? lockTimeout, + bool? flush}); + + /// Flush changes to the underlying storage. + /// + /// When this returns, all changes previously written will be persisted + /// to storage. + /// + /// This only has an effect when IndexedDB storage is used. + Future flush(); +} diff --git a/packages/sqlite_async/pubspec.yaml b/packages/sqlite_async/pubspec.yaml index 4523ae7..3d5130d 100644 --- a/packages/sqlite_async/pubspec.yaml +++ b/packages/sqlite_async/pubspec.yaml @@ -1,9 +1,9 @@ name: sqlite_async description: High-performance asynchronous interface for SQLite on Dart and Flutter. -version: 0.8.0 +version: 0.12.2 repository: https://github.com/powersync-ja/sqlite_async.dart environment: - sdk: ">=3.4.0 <4.0.0" + sdk: ">=3.5.0 <4.0.0" topics: - sqlite @@ -12,17 +12,19 @@ topics: - flutter dependencies: - sqlite3: "^2.4.4" - sqlite3_web: ^0.1.2-wip + sqlite3: ^2.9.0 + sqlite3_web: ^0.3.2 async: ^2.10.0 collection: ^1.17.0 mutex: ^3.1.0 meta: ^1.10.0 + web: ^1.0.0 dev_dependencies: - dcli: ^4.0.0 - js: ^0.6.7 - lints: ^3.0.0 + build_runner: ^2.4.14 + build_web_compilers: ^4.1.1 + build_test: ^2.2.3 + lints: ^5.0.0 test: ^1.21.0 test_api: ^0.7.0 glob: ^2.1.1 @@ -31,6 +33,8 @@ dev_dependencies: shelf_static: ^1.1.2 stream_channel: ^2.1.2 path: ^1.9.0 + test_descriptor: ^2.0.2 + fake_async: ^1.3.3 platforms: android: diff --git a/packages/sqlite_async/test/basic_test.dart b/packages/sqlite_async/test/basic_test.dart index e07daf4..e2914b3 100644 --- a/packages/sqlite_async/test/basic_test.dart +++ b/packages/sqlite_async/test/basic_test.dart @@ -6,6 +6,8 @@ import 'package:test/test.dart'; import 'utils/test_utils_impl.dart'; final testUtils = TestUtils(); +const _isDart2Wasm = bool.fromEnvironment('dart.tool.dart2wasm'); +const _isWeb = identical(0, 0.0) || _isDart2Wasm; void main() { group('Shared Basic Tests', () { @@ -121,52 +123,227 @@ void main() { ['Test Data']); expect(rs.rows[0], equals(['Test Data'])); }); - expect(await savedTx!.getAutoCommit(), equals(true)); + expect(await db.getAutoCommit(), equals(true)); expect(savedTx!.closed, equals(true)); }); - test('should properly report errors in transactions', () async { - final db = await testUtils.setupDatabase(path: path); - await createTables(db); + test( + 'should properly report errors in transactions', + () async { + final db = await testUtils.setupDatabase(path: path); + await createTables(db); - var tp = db.writeTransaction((tx) async { - await tx.execute( - 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', - [1, 'test1']); - await tx.execute( - 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', - [2, 'test2']); - expect(await tx.getAutoCommit(), equals(false)); - try { + var tp = db.writeTransaction((tx) async { await tx.execute( 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', - [2, 'test3']); - } catch (e) { - // Ignore - } + [1, 'test1']); + await tx.execute( + 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', + [2, 'test2']); + expect(await tx.getAutoCommit(), equals(false)); + try { + await tx.execute( + 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', + [2, 'test3']); + } catch (e) { + // Ignore + } - expect(await tx.getAutoCommit(), equals(true)); - expect(tx.closed, equals(false)); + expect(await tx.getAutoCommit(), equals(true)); + expect(tx.closed, equals(false)); - // Will not be executed because of the above rollback - await tx.execute( - 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', - [4, 'test4']); + // Will not be executed because of the above rollback + await tx.execute( + 'INSERT OR ROLLBACK INTO test_data(id, description) VALUES(?, ?)', + [4, 'test4']); + }); + + // The error propagates up to the transaction + await expectLater( + tp, + throwsA((e) => + e is SqliteException && + e.message + .contains('Transaction rolled back by earlier statement'))); + + expect(await db.get('SELECT count() count FROM test_data'), + equals({'count': 0})); + + // Check that we can open another transaction afterwards + await db.writeTransaction((tx) async {}); + }, + skip: _isDart2Wasm + ? 'Fails due to compiler bug, https://dartbug.com/59981' + : null, + ); + + test('reports exceptions as SqliteExceptions', () async { + final db = await testUtils.setupDatabase(path: path); + await expectLater( + db.get('SELECT invalid_statement;'), + throwsA( + isA() + .having((e) => e.causingStatement, 'causingStatement', + 'SELECT invalid_statement;') + .having((e) => e.extendedResultCode, 'extendedResultCode', 1), + ), + ); + }); + + group('nested transaction', () { + const insert = 'INSERT INTO test_data (description) VALUES(?);'; + late SqliteDatabase db; + + setUp(() async { + db = await testUtils.setupDatabase(path: path); + await createTables(db); + }); + + tearDown(() => db.close()); + + test('run in outer transaction', () async { + await db.writeTransaction((tx) async { + await tx.execute(insert, ['first']); + + await tx.writeTransaction((tx) async { + await tx.execute(insert, ['second']); + }); + + expect(await tx.getAll('SELECT * FROM test_data'), hasLength(2)); + }); + + expect(await db.getAll('SELECT * FROM test_data'), hasLength(2)); + }); + + test('can rollback inner transaction', () async { + await db.writeTransaction((tx) async { + await tx.execute(insert, ['first']); + + await tx.writeTransaction((tx) async { + await tx.execute(insert, ['second']); + }); + + await expectLater(() async { + await tx.writeTransaction((tx) async { + await tx.execute(insert, ['third']); + expect(await tx.getAll('SELECT * FROM test_data'), hasLength(3)); + throw 'rollback please'; + }); + }, throwsA(anything)); + + expect(await tx.getAll('SELECT * FROM test_data'), hasLength(2)); + }); + + expect(await db.getAll('SELECT * FROM test_data'), hasLength(2)); + }); + + test('cannot use outer transaction while inner is active', () async { + await db.writeTransaction((outer) async { + await outer.writeTransaction((inner) async { + await expectLater(outer.execute('SELECT 1'), throwsStateError); + }); + }); + }); + + test('cannot use inner after leaving scope', () async { + await db.writeTransaction((tx) async { + late SqliteWriteContext inner; + await tx.writeTransaction((tx) async { + inner = tx; + }); + + await expectLater(inner.execute('SELECT 1'), throwsStateError); + }); + }); + }); + + test('can use raw database instance', () async { + final factory = await testUtils.testFactory(); + final raw = await factory.openDatabaseForSingleConnection(); + // Creating a fuction ensures that this database is actually used - if + // a connection were set up in a background isolate, it wouldn't have this + // function. + raw.createFunction( + functionName: 'my_function', function: (args) => 'test'); + + final db = SqliteDatabase.singleConnection( + SqliteConnection.synchronousWrapper(raw)); + await createTables(db); + + expect(db.updates, emits(UpdateNotification({'test_data'}))); + await db + .execute('INSERT INTO test_data(description) VALUES (my_function())'); + + expect(await db.get('SELECT description FROM test_data'), + {'description': 'test'}); + }); + + test('respects lock timeouts', () async { + // Unfortunately this test can't use fakeAsync because it uses actual + // lock APIs on the web. + final db = await testUtils.setupDatabase(path: path); + final lockAcquired = Completer(); + + final completion = db.writeLock((context) async { + lockAcquired.complete(); + await Future.delayed(const Duration(seconds: 1)); }); - // The error propagates up to the transaction + await lockAcquired.future; await expectLater( - tp, - throwsA((e) => - e is SqliteException && - e.message - .contains('Transaction rolled back by earlier statement'))); + () => db.writeLock( + lockTimeout: Duration(milliseconds: 200), (_) async => {}), + throwsA(isA()), + ); + + await completion; + }, onPlatform: { + 'browser': Skip( + 'Web locks are managed with a shared worker, which does not support timeouts', + ) + }); + + test('with all connections', () async { + final maxReaders = _isWeb ? 0 : 3; - expect(await db.get('SELECT count() count FROM test_data'), - equals({'count': 0})); + final db = SqliteDatabase.withFactory( + await testUtils.testFactory(path: path), + maxReaders: maxReaders, + ); + await db.initialize(); + await createTables(db); + + // Warm up to spawn the max readers + await Future.wait([for (var i = 0; i < 10; i++) db.get('SELECT $i')]); + + bool finishedWithAllConns = false; + + late Future readsCalledWhileWithAllConnsRunning; + + final parentZone = Zone.current; + await db.withAllConnections((writer, readers) async { + expect(readers.length, maxReaders); + + // Run some reads during the block that they should run after the block finishes and releases + // all locks + // Need a root zone here to avoid recursive lock errors. + readsCalledWhileWithAllConnsRunning = + Future(parentZone.bindCallback(() async { + await Future.wait( + [1, 2, 3, 4, 5, 6, 7, 8].map((i) async { + await db.readLock((c) async { + expect(finishedWithAllConns, isTrue); + await Future.delayed(const Duration(milliseconds: 100)); + }); + }), + ); + })); + + await Future.delayed(const Duration(milliseconds: 200)); + finishedWithAllConns = true; + }); - // Check that we can open another transaction afterwards - await db.writeTransaction((tx) async {}); + await readsCalledWhileWithAllConnsRunning; }); }); } diff --git a/packages/sqlite_async/test/close_test.dart b/packages/sqlite_async/test/close_test.dart index dcb3390..6a72f3d 100644 --- a/packages/sqlite_async/test/close_test.dart +++ b/packages/sqlite_async/test/close_test.dart @@ -1,4 +1,6 @@ @TestOn('!browser') +library; + import 'dart:io'; import 'package:sqlite_async/sqlite_async.dart'; diff --git a/packages/sqlite_async/test/isolate_test.dart b/packages/sqlite_async/test/isolate_test.dart index 60bea87..3a3af98 100644 --- a/packages/sqlite_async/test/isolate_test.dart +++ b/packages/sqlite_async/test/isolate_test.dart @@ -1,4 +1,6 @@ @TestOn('!browser') +library; + import 'dart:isolate'; import 'package:test/test.dart'; diff --git a/packages/sqlite_async/test/mutex_test.dart b/packages/sqlite_async/test/mutex_test.dart index 699a877..3f492d6 100644 --- a/packages/sqlite_async/test/mutex_test.dart +++ b/packages/sqlite_async/test/mutex_test.dart @@ -1,67 +1,83 @@ -@TestOn('!browser') -import 'dart:isolate'; +import 'dart:async'; +import 'dart:math'; -import 'package:sqlite_async/src/native/native_isolate_mutex.dart'; +import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; +import 'utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + void main() { - group('Mutex Tests', () { - test('Closing', () async { - // Test that locks are properly released when calling SharedMutex.close() - // in in Isolate. - // A timeout in this test indicates a likely error. - for (var i = 0; i < 50; i++) { - final mutex = SimpleMutex(); - final serialized = mutex.shared; - - final result = await Isolate.run(() async { - return _lockInIsolate(serialized); + group('Shared Mutex Tests', () { + test('Queue exclusive operations', () async { + final m = Mutex(); + final collection = List.generate(10, (index) => index); + final results = []; + + final futures = collection.map((element) async { + return m.lock(() async { + // Simulate some asynchronous work + await Future.delayed(Duration(milliseconds: Random().nextInt(100))); + results.add(element); + return element; }); + }).toList(); - await mutex.lock(() async {}); + // Await all the promises + await Future.wait(futures); - expect(result, equals(5)); - } + // Check if the results are in ascending order + expect(results, equals(collection)); }); + }); - test('Re-use after closing', () async { - // Test that shared locks can be opened and closed multiple times. - final mutex = SimpleMutex(); - final serialized = mutex.shared; + test('Timeout should throw a TimeoutException', () async { + final m = Mutex(); + m.lock(() async { + await Future.delayed(Duration(milliseconds: 300)); + }); - final result = await Isolate.run(() async { - return _lockInIsolate(serialized); - }); + await expectLater( + m.lock(() async { + print('This should not get executed'); + }, timeout: Duration(milliseconds: 200)), + throwsA((e) => + e is TimeoutException && + e.message!.contains('Failed to acquire lock'))); + }); - final result2 = await Isolate.run(() async { - return _lockInIsolate(serialized); - }); + test('In-time timeout should function normally', () async { + final m = Mutex(); + final results = []; + m.lock(() async { + await Future.delayed(Duration(milliseconds: 100)); + results.add(1); + }); - await mutex.lock(() async {}); + await m.lock(() async { + results.add(2); + }, timeout: Duration(milliseconds: 200)); - expect(result, equals(5)); - expect(result2, equals(5)); - }); - }, timeout: const Timeout(Duration(milliseconds: 5000))); -} + expect(results, equals([1, 2])); + }); -Future _lockInIsolate( - SerializedMutex smutex, -) async { - final mutex = smutex.open(); - // Start a "thread" that repeatedly takes a lock - _infiniteLock(mutex).ignore(); - await Future.delayed(const Duration(milliseconds: 10)); - // Then close the mutex while the above loop is running. - await mutex.close(); - - return 5; -} + test('Different Mutex instances should cause separate locking', () async { + final m1 = Mutex(); + final m2 = Mutex(); -Future _infiniteLock(SharedMutex mutex) async { - while (true) { - await mutex.lock(() async { - await Future.delayed(const Duration(milliseconds: 1)); + final results = []; + final p1 = m1.lock(() async { + await Future.delayed(Duration(milliseconds: 300)); + results.add(1); }); - } + + final p2 = m2.lock(() async { + results.add(2); + }); + + await p1; + await p2; + expect(results, equals([2, 1])); + }); } diff --git a/packages/sqlite_async/test/native/basic_test.dart b/packages/sqlite_async/test/native/basic_test.dart index 263ab39..3f348e6 100644 --- a/packages/sqlite_async/test/native/basic_test.dart +++ b/packages/sqlite_async/test/native/basic_test.dart @@ -1,11 +1,17 @@ @TestOn('!browser') +library; + import 'dart:async'; +import 'dart:io'; import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart' show join; import 'package:sqlite3/common.dart' as sqlite; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; +import '../utils/abstract_test_utils.dart'; import '../utils/test_utils_impl.dart'; final testUtils = TestUtils(); @@ -98,6 +104,126 @@ void main() { print("${DateTime.now()} done"); }); + test('prevent opening new readers while in withAllConnections', () async { + final sharedStateDir = Directory.systemTemp.createTempSync(); + addTearDown(() => sharedStateDir.deleteSync(recursive: true)); + + final File sharedStateFile = + File(join(sharedStateDir.path, 'shared-state.txt')); + + sharedStateFile.writeAsStringSync('initial'); + + final db = SqliteDatabase.withFactory( + _TestSqliteOpenFactoryWithSharedStateFile( + path: path, sharedStateFilePath: sharedStateFile.path), + maxReaders: 3); + await db.initialize(); + await createTables(db); + + // The writer saw 'initial' in the file when opening the connection + expect( + await db + .writeLock((c) => c.get('SELECT file_contents_on_open() AS state')), + {'state': 'initial'}, + ); + + final withAllConnectionsCompleter = Completer(); + + final withAllConnsFut = db.withAllConnections((writer, readers) async { + expect(readers.length, 0); // No readers yet + + // Simulate some work until the file is updated + await Future.delayed(const Duration(milliseconds: 200)); + sharedStateFile.writeAsStringSync('updated'); + + await withAllConnectionsCompleter.future; + }); + + // Start a reader that gets the contents of the shared file + bool readFinished = false; + final someReadFut = + db.get('SELECT file_contents_on_open() AS state', []).then((r) { + readFinished = true; + return r; + }); + + // The withAllConnections should prevent the reader from opening + await Future.delayed(const Duration(milliseconds: 100)); + expect(readFinished, isFalse); + + // Free all the locks + withAllConnectionsCompleter.complete(); + await withAllConnsFut; + + final readerInfo = await someReadFut; + expect(readFinished, isTrue); + // The read should see the updated value in the file. This checks + // that a reader doesn't spawn while running withAllConnections + expect(readerInfo, {'state': 'updated'}); + }); + + test('with all connections', () async { + final maxReaders = 3; + + final db = SqliteDatabase.withFactory( + await testUtils.testFactory(path: path), + maxReaders: maxReaders, + ); + await db.initialize(); + await createTables(db); + + Future readWithRandomDelay( + SqliteReadContext ctx, int id) async { + return await ctx.get( + 'SELECT ? as i, test_sleep(?) as sleep, test_connection_name() as connection', + [id, 5 + Random().nextInt(10)]); + } + + // Warm up to spawn the max readers + await Future.wait( + [1, 2, 3, 4, 5, 6, 7, 8].map((i) => readWithRandomDelay(db, i)), + ); + + bool finishedWithAllConns = false; + + late Future readsCalledWhileWithAllConnsRunning; + + print("${DateTime.now()} start"); + await db.withAllConnections((writer, readers) async { + expect(readers.length, maxReaders); + + // Run some reads during the block that they should run after the block finishes and releases + // all locks + readsCalledWhileWithAllConnsRunning = Future.wait( + [1, 2, 3, 4, 5, 6, 7, 8].map((i) async { + final r = await db.readLock((c) async { + expect(finishedWithAllConns, isTrue); + return await readWithRandomDelay(c, i); + }); + print( + "${DateTime.now()} After withAllConnections, started while running $r"); + }), + ); + + await Future.wait([ + writer.execute( + "INSERT OR REPLACE INTO test_data(id, description) SELECT ? as i, test_sleep(?) || ' ' || test_connection_name() || ' 1 ' || datetime() as connection RETURNING *", + [ + 123, + 5 + Random().nextInt(20) + ]).then((value) => + print("${DateTime.now()} withAllConnections writer done $value")), + ...readers + .mapIndexed((i, r) => readWithRandomDelay(r, i).then((results) { + print( + "${DateTime.now()} withAllConnections readers done $results"); + })) + ]); + }).then((_) => finishedWithAllConns = true); + + await readsCalledWhileWithAllConnsRunning; + }); + test('read-only transactions', () async { final db = await testUtils.setupDatabase(path: path); await createTables(db); @@ -342,6 +468,22 @@ void main() { await Future.wait([f1, f2]); }); + + test('reports open error', () async { + // Ensure that a db that fails to open doesn't report any unhandled + // exceptions. This could happen when e.g. SQLCipher is used and the open + // factory supplies a wrong key pragma (because a subsequent pragma to + // change the journal mode then fails with a "not a database" error). + final db = + SqliteDatabase.withFactory(_InvalidPragmaOnOpenFactory(path: path)); + await expectLater( + db.initialize(), + throwsA( + isA().having( + (e) => e.toString(), 'toString()', contains('syntax error')), + ), + ); + }); }); } @@ -349,3 +491,43 @@ void main() { void ignore(Future future) { future.then((_) {}, onError: (_) {}); } + +class _InvalidPragmaOnOpenFactory extends DefaultSqliteOpenFactory { + const _InvalidPragmaOnOpenFactory({required super.path}); + + @override + List pragmaStatements(SqliteOpenOptions options) { + return [ + 'invalid syntax to fail open in test', + ...super.pragmaStatements(options), + ]; + } +} + +class _TestSqliteOpenFactoryWithSharedStateFile + extends TestDefaultSqliteOpenFactory { + final String sharedStateFilePath; + + _TestSqliteOpenFactoryWithSharedStateFile( + {required super.path, required this.sharedStateFilePath}); + + @override + sqlite.CommonDatabase open(SqliteOpenOptions options) { + final File sharedStateFile = File(sharedStateFilePath); + final String sharedState = sharedStateFile.readAsStringSync(); + + final db = super.open(options); + + // Function to return the contents of the shared state file at the time of opening + // so that we know at which point the factory was called. + db.createFunction( + functionName: 'file_contents_on_open', + argumentCount: const sqlite.AllowedArgumentCount(0), + function: (args) { + return sharedState; + }, + ); + + return db; + } +} diff --git a/packages/sqlite_async/test/native/native_mutex_test.dart b/packages/sqlite_async/test/native/native_mutex_test.dart new file mode 100644 index 0000000..e9b5a54 --- /dev/null +++ b/packages/sqlite_async/test/native/native_mutex_test.dart @@ -0,0 +1,69 @@ +@TestOn('!browser') +library; + +import 'dart:isolate'; + +import 'package:sqlite_async/src/native/native_isolate_mutex.dart'; +import 'package:test/test.dart'; + +void main() { + group('Mutex Tests', () { + test('Closing', () async { + // Test that locks are properly released when calling SharedMutex.close() + // in in Isolate. + // A timeout in this test indicates a likely error. + for (var i = 0; i < 50; i++) { + final mutex = SimpleMutex(); + final serialized = mutex.shared; + + final result = await Isolate.run(() async { + return _lockInIsolate(serialized); + }); + + await mutex.lock(() async {}); + + expect(result, equals(5)); + } + }); + + test('Re-use after closing', () async { + // Test that shared locks can be opened and closed multiple times. + final mutex = SimpleMutex(); + final serialized = mutex.shared; + + final result = await Isolate.run(() async { + return _lockInIsolate(serialized); + }); + + final result2 = await Isolate.run(() async { + return _lockInIsolate(serialized); + }); + + await mutex.lock(() async {}); + + expect(result, equals(5)); + expect(result2, equals(5)); + }); + }, timeout: const Timeout(Duration(milliseconds: 5000))); +} + +Future _lockInIsolate( + SerializedMutex smutex, +) async { + final mutex = smutex.open(); + // Start a "thread" that repeatedly takes a lock + _infiniteLock(mutex).ignore(); + await Future.delayed(const Duration(milliseconds: 10)); + // Then close the mutex while the above loop is running. + await mutex.close(); + + return 5; +} + +Future _infiniteLock(SharedMutex mutex) async { + while (true) { + await mutex.lock(() async { + await Future.delayed(const Duration(milliseconds: 1)); + }); + } +} diff --git a/packages/sqlite_async/test/native/schema_test.dart b/packages/sqlite_async/test/native/schema_test.dart new file mode 100644 index 0000000..423d33b --- /dev/null +++ b/packages/sqlite_async/test/native/schema_test.dart @@ -0,0 +1,100 @@ +@TestOn('!browser') +library; + +import 'dart:async'; + +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:sqlite_async/src/utils/shared_utils.dart'; +import 'package:test/test.dart'; + +import '../utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +void main() { + group('Schema Tests', () { + late String path; + + setUp(() async { + path = testUtils.dbPath(); + await testUtils.cleanDb(path: path); + }); + + tearDown(() async { + await testUtils.cleanDb(path: path); + }); + + createTables(SqliteDatabase db) async { + await db.writeTransaction((tx) async { + await tx.execute( + 'CREATE TABLE _customers(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + await tx.execute( + 'CREATE TABLE _local_customers(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); + await tx + .execute('CREATE VIEW customers AS SELECT * FROM _local_customers'); + }); + } + + updateTables(SqliteDatabase db) async { + await db.writeTransaction((tx) async { + await tx.execute('DROP VIEW IF EXISTS customers'); + await tx.execute('CREATE VIEW customers AS SELECT * FROM _customers'); + }); + } + + test('should refresh schema views', () async { + final db = await testUtils.setupDatabase(path: path); + await createTables(db); + + final customerTables = + await getSourceTables(db, "select * from customers"); + expect(customerTables.contains('_local_customers'), true); + await updateTables(db); + + // without this, source tables are outdated + await db.refreshSchema(); + + final updatedCustomerTables = + await getSourceTables(db, "select * from customers"); + expect(updatedCustomerTables.contains('_customers'), true); + }); + + test('should complete refresh schema after transaction', () async { + var completer1 = Completer(); + var transactionCompleted = false; + + final db = await testUtils.setupDatabase(path: path); + await createTables(db); + + // Start a read transaction + db.readTransaction((tx) async { + completer1.complete(); + await tx.get('select test_sleep(2000)'); + + transactionCompleted = true; + }); + + // Wait for the transaction to start + await completer1.future; + + var refreshSchemaFuture = db.refreshSchema(); + + // Setup check that refreshSchema completes after the transaction has completed + var refreshAfterTransaction = false; + refreshSchemaFuture.then((_) { + if (transactionCompleted) { + refreshAfterTransaction = true; + } + }); + + await refreshSchemaFuture; + + expect(refreshAfterTransaction, isTrue, + reason: 'refreshSchema completed before transaction finished'); + + // Sanity check + expect(transactionCompleted, isTrue, + reason: 'Transaction did not complete as expected'); + }); + }); +} diff --git a/packages/sqlite_async/test/native/watch_test.dart b/packages/sqlite_async/test/native/watch_test.dart index 67077db..0a97b17 100644 --- a/packages/sqlite_async/test/native/watch_test.dart +++ b/packages/sqlite_async/test/native/watch_test.dart @@ -1,10 +1,13 @@ @TestOn('!browser') +library; + import 'dart:async'; import 'dart:isolate'; import 'dart:math'; import 'package:sqlite3/common.dart'; import 'package:sqlite_async/sqlite_async.dart'; +import 'package:sqlite_async/src/utils/shared_utils.dart'; import 'package:test/test.dart'; import '../utils/test_utils_impl.dart'; @@ -29,6 +32,51 @@ void main() { return db; }); + test('raw update notifications', () async { + final factory = await testUtils.testFactory(path: path); + final db = factory + .openDB(SqliteOpenOptions(primaryConnection: true, readOnly: false)); + + db.execute('CREATE TABLE a (bar INTEGER);'); + db.execute('CREATE TABLE b (bar INTEGER);'); + final events = >[]; + final subscription = db.updatedTables.listen(events.add); + + db.execute('insert into a default values'); + expect(events, isEmpty); // should be async + await pumpEventQueue(); + expect(events.removeLast(), {'a'}); + + db.execute('begin'); + db.execute('insert into a default values'); + db.execute('insert into b default values'); + await pumpEventQueue(); + expect(events, isEmpty); // should only trigger on commit + db.execute('commit'); + + await pumpEventQueue(); + expect(events.removeLast(), {'a', 'b'}); + + db.execute('begin'); + db.execute('insert into a default values'); + db.execute('rollback'); + expect(events, isEmpty); + await pumpEventQueue(); + expect(events, isEmpty); // should ignore cancelled transactions + + // Should still listen during pause, and dispatch on resume + subscription.pause(); + db.execute('insert into a default values'); + await pumpEventQueue(); + expect(events, isEmpty); + + subscription.resume(); + await pumpEventQueue(); + expect(events.removeLast(), {'a'}); + + subscription.pause(); + }); + test('watch in isolate', () async { final db = await testUtils.setupDatabase(path: path); await createTables(db); diff --git a/packages/sqlite_async/test/server/worker_server.dart b/packages/sqlite_async/test/server/worker_server.dart index 30cffe9..4d060e2 100644 --- a/packages/sqlite_async/test/server/worker_server.dart +++ b/packages/sqlite_async/test/server/worker_server.dart @@ -1,6 +1,5 @@ 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; @@ -10,8 +9,7 @@ import 'package:stream_channel/stream_channel.dart'; import 'asset_server.dart'; Future hybridMain(StreamChannel channel) async { - final directory = p.normalize( - p.join(DartScript.self.pathToScriptDirectory, '../../../../assets')); + final directory = p.normalize('../../assets'); final sqliteOutputPath = p.join(directory, 'sqlite3.wasm'); diff --git a/packages/sqlite_async/test/update_notification_test.dart b/packages/sqlite_async/test/update_notification_test.dart new file mode 100644 index 0000000..05c94c6 --- /dev/null +++ b/packages/sqlite_async/test/update_notification_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:sqlite_async/src/update_notification.dart'; +import 'package:test/test.dart'; + +void main() { + group('Update notifications', () { + const timeout = Duration(seconds: 10); + const halfTimeout = Duration(seconds: 5); + + group('throttle', () { + test('can add initial', () { + fakeAsync((control) { + final source = StreamController(sync: true); + final events = []; + + UpdateNotification.throttleStream(source.stream, timeout, + addOne: UpdateNotification({'a'})).listen(events.add); + + control.flushMicrotasks(); + expect(events, hasLength(1)); + control.elapse(halfTimeout); + + source.add(UpdateNotification({'b'})); + expect(events, hasLength(1)); // Still a delay from the initial one + + control.elapse(halfTimeout); + expect(events, hasLength(2)); + }); + }); + + test('sends events after initial throttle', () { + fakeAsync((control) { + final source = StreamController(sync: true); + final events = []; + + UpdateNotification.throttleStream(source.stream, timeout) + .listen(events.add); + + source.add(UpdateNotification({'a'})); + control.elapse(halfTimeout); + expect(events, isEmpty); + + control.elapse(halfTimeout); + expect(events, hasLength(1)); + }); + }); + + test('increases delay after pause', () { + fakeAsync((control) { + final source = StreamController(sync: true); + final events = []; + + final sub = UpdateNotification.throttleStream(source.stream, timeout) + .listen(null); + sub.onData((event) { + events.add(event); + sub.pause(); + }); + + source.add(UpdateNotification({'a'})); + control.elapse(timeout); + expect(events, hasLength(1)); + + // Assume the stream stays paused for the timeout window that would + // be created after emitting the notification. + control.elapse(timeout * 2); + source.add(UpdateNotification({'b'})); + control.elapse(timeout * 2); + + // A full timeout needs to pass after resuming before a new item is + // emitted. + sub.resume(); + expect(events, hasLength(1)); + + control.elapse(halfTimeout); + expect(events, hasLength(1)); + control.elapse(halfTimeout); + expect(events, hasLength(2)); + }); + }); + + test('does not introduce artificial delay in pause', () { + fakeAsync((control) { + final source = StreamController(sync: true); + final events = []; + + final sub = UpdateNotification.throttleStream(source.stream, timeout) + .listen(events.add); + + // Await the initial delay + control.elapse(timeout); + + sub.pause(); + source.add(UpdateNotification({'a'})); + // Resuming should not introduce a timeout window because no window + // was active when the stream was paused. + sub.resume(); + control.flushMicrotasks(); + expect(events, hasLength(1)); + }); + }); + + test('merges events', () { + fakeAsync((control) { + final source = StreamController(sync: true); + final events = []; + + UpdateNotification.throttleStream(source.stream, timeout) + .listen(events.add); + + source.add(UpdateNotification({'a'})); + control.elapse(halfTimeout); + expect(events, isEmpty); + + source.add(UpdateNotification({'b'})); + control.elapse(halfTimeout); + expect(events, [ + UpdateNotification({'a', 'b'}) + ]); + }); + }); + + test('forwards cancellations', () { + fakeAsync((control) { + var cancelled = false; + final source = StreamController(sync: true) + ..onCancel = () => cancelled = true; + + final sub = UpdateNotification.throttleStream(source.stream, timeout) + .listen((_) => fail('unexpected event'), + onDone: () => fail('unexpected done')); + + source.add(UpdateNotification({'a'})); + control.elapse(halfTimeout); + + sub.cancel(); + control.flushTimers(); + + expect(cancelled, isTrue); + expect(control.pendingTimers, isEmpty); + }); + }); + + test('closes when source closes', () { + fakeAsync((control) { + final source = StreamController(sync: true) + ..onCancel = () => Future.value(); + final events = []; + var done = false; + + UpdateNotification.throttleStream(source.stream, timeout) + .listen(events.add, onDone: () => done = true); + + source + // These two are combined due to throttleFirst + ..add(UpdateNotification({'a'})) + ..add(UpdateNotification({'b'})) + ..close(); + + control.flushTimers(); + expect(events, [ + UpdateNotification({'a', 'b'}) + ]); + expect(done, isTrue); + expect(control.pendingTimers, isEmpty); + }); + }); + + test('closes when source closes after delay', () { + fakeAsync((control) { + final source = StreamController(sync: true) + ..onCancel = () => Future.value(); + final events = []; + var done = false; + + UpdateNotification.throttleStream(source.stream, timeout) + .listen(events.add, onDone: () => done = true); + + control.elapse(const Duration(hours: 1)); + source.close(); + + control.flushTimers(); + expect(events, isEmpty); + expect(done, isTrue); + expect(control.pendingTimers, isEmpty); + }); + }); + }); + + test('filter tables', () async { + final source = StreamController(sync: true); + final events = []; + final subscription = UpdateNotification.filterTablesTransformer(['a']) + .bind(source.stream) + .listen(events.add); + + source.add(UpdateNotification({'a', 'b'})); + expect(events, hasLength(1)); + + source.add(UpdateNotification({'b'})); + expect(events, hasLength(1)); + + await subscription.cancel(); + expect(source.hasListener, isFalse); + }); + }); +} diff --git a/packages/sqlite_async/test/utils/abstract_test_utils.dart b/packages/sqlite_async/test/utils/abstract_test_utils.dart index f1ec6ea..b388c4d 100644 --- a/packages/sqlite_async/test/utils/abstract_test_utils.dart +++ b/packages/sqlite_async/test/utils/abstract_test_utils.dart @@ -1,22 +1,19 @@ +import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; -import 'package:test_api/src/backend/invoker.dart'; class TestDefaultSqliteOpenFactory extends DefaultSqliteOpenFactory { final String sqlitePath; TestDefaultSqliteOpenFactory( {required super.path, super.sqliteOptions, this.sqlitePath = ''}); + + Future openDatabaseForSingleConnection() async { + return openDB(SqliteOpenOptions(primaryConnection: true, readOnly: false)); + } } 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; - } + String dbPath(); /// Generates a test open factory Future testFactory( diff --git a/packages/sqlite_async/test/utils/native_test_utils.dart b/packages/sqlite_async/test/utils/native_test_utils.dart index e23bb65..83dea18 100644 --- a/packages/sqlite_async/test/utils/native_test_utils.dart +++ b/packages/sqlite_async/test/utils/native_test_utils.dart @@ -5,9 +5,11 @@ import 'dart:isolate'; import 'package:glob/glob.dart'; import 'package:glob/list_local_fs.dart'; +import 'package:sqlite_async/sqlite3.dart'; import 'package:sqlite_async/sqlite3_common.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:sqlite3/open.dart' as sqlite_open; +import 'package:test_descriptor/test_descriptor.dart' as d; import 'abstract_test_utils.dart'; @@ -20,11 +22,25 @@ class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { super.sqlitePath = defaultSqlitePath, initStatements}); - @override - CommonDatabase open(SqliteOpenOptions options) { + void _applyOpenOverrides() { sqlite_open.open.overrideFor(sqlite_open.OperatingSystem.linux, () { return DynamicLibrary.open(sqlitePath); }); + + sqlite_open.open.overrideFor(sqlite_open.OperatingSystem.macOS, () { + // Prefer using Homebrew's SQLite which allows loading extensions. + const fromHomebrew = '/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib'; + if (File(fromHomebrew).existsSync()) { + return DynamicLibrary.open(fromHomebrew); + } + + return DynamicLibrary.open('libsqlite3.dylib'); + }); + } + + @override + CommonDatabase open(SqliteOpenOptions options) { + _applyOpenOverrides(); final db = super.open(options); db.createFunction( @@ -47,13 +63,18 @@ class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { return db; } + + @override + Future openDatabaseForSingleConnection() async { + _applyOpenOverrides(); + return sqlite3.openInMemory(); + } } class TestUtils extends AbstractTestUtils { @override String dbPath() { - Directory("test-db").createSync(recursive: false); - return super.dbPath(); + return d.path('test.db'); } @override diff --git a/packages/sqlite_async/test/utils/stub_test_utils.dart b/packages/sqlite_async/test/utils/stub_test_utils.dart index 5e3a953..852009f 100644 --- a/packages/sqlite_async/test/utils/stub_test_utils.dart +++ b/packages/sqlite_async/test/utils/stub_test_utils.dart @@ -1,6 +1,11 @@ import 'abstract_test_utils.dart'; class TestUtils extends AbstractTestUtils { + @override + String dbPath() { + throw UnimplementedError(); + } + @override Future cleanDb({required String path}) { throw UnimplementedError(); diff --git a/packages/sqlite_async/test/utils/test_utils_impl.dart b/packages/sqlite_async/test/utils/test_utils_impl.dart index 99a34d3..3406d1e 100644 --- a/packages/sqlite_async/test/utils/test_utils_impl.dart +++ b/packages/sqlite_async/test/utils/test_utils_impl.dart @@ -2,4 +2,4 @@ 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'; + if (dart.library.js_interop) 'web_test_utils.dart'; diff --git a/packages/sqlite_async/test/utils/web_test_utils.dart b/packages/sqlite_async/test/utils/web_test_utils.dart index 2aec683..33f7d64 100644 --- a/packages/sqlite_async/test/utils/web_test_utils.dart +++ b/packages/sqlite_async/test/utils/web_test_utils.dart @@ -1,14 +1,32 @@ import 'dart:async'; -import 'dart:html'; +import 'dart:js_interop'; +import 'dart:math'; -import 'package:js/js.dart'; +import 'package:sqlite_async/sqlite3_wasm.dart'; import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; +import 'package:web/web.dart' show Blob, BlobPart, BlobPropertyBag; import 'abstract_test_utils.dart'; @JS('URL.createObjectURL') external String _createObjectURL(Blob blob); +String? _dbPath; + +class TestSqliteOpenFactory extends TestDefaultSqliteOpenFactory { + TestSqliteOpenFactory( + {required super.path, super.sqliteOptions, super.sqlitePath = ''}); + + @override + Future openDatabaseForSingleConnection() async { + final sqlite = await WasmSqlite3.loadFromUrl( + Uri.parse(sqliteOptions.webSqliteOptions.wasmUri)); + sqlite.registerVirtualFileSystem(InMemoryFileSystem(), makeDefault: true); + + return sqlite.openInMemory(); + } +} + class TestUtils extends AbstractTestUtils { late Future _isInitialized; late final SqliteOptions webOptions; @@ -19,13 +37,13 @@ class TestUtils extends AbstractTestUtils { Future _init() async { final channel = spawnHybridUri('/test/server/worker_server.dart'); - final port = await channel.stream.first as int; + final port = (await channel.stream.first as num).toInt(); final sqliteWasmUri = 'http://localhost:$port/sqlite3.wasm'; // Cross origin workers are not supported, but we can supply a Blob var sqliteUri = 'http://localhost:$port/db_worker.js'; - final blob = Blob( - ['importScripts("$sqliteUri");'], 'application/javascript'); + final blob = Blob(['importScripts("$sqliteUri");'.toJS].toJS, + BlobPropertyBag(type: 'application/javascript')); sqliteUri = _createObjectURL(blob); webOptions = SqliteOptions( @@ -33,18 +51,36 @@ class TestUtils extends AbstractTestUtils { wasmUri: sqliteWasmUri.toString(), workerUri: sqliteUri)); } + @override + String dbPath() { + if (_dbPath case final path?) { + return path; + } + + final created = _dbPath = 'test-db/${Random().nextInt(1 << 31)}/test.db'; + addTearDown(() { + // Pick a new path for the next test. + _dbPath = null; + }); + + return created; + } + @override Future cleanDb({required String path}) async {} @override Future testFactory( {String? path, - String? sqlitePath, + String sqlitePath = '', List initStatements = const [], SqliteOptions options = const SqliteOptions.defaults()}) async { await _isInitialized; - return super.testFactory( - path: path, options: webOptions, initStatements: initStatements); + return TestSqliteOpenFactory( + path: path ?? dbPath(), + sqlitePath: sqlitePath, + sqliteOptions: webOptions, + ); } @override diff --git a/packages/sqlite_async/test/watch_test.dart b/packages/sqlite_async/test/watch_test.dart index e0ef765..dc28add 100644 --- a/packages/sqlite_async/test/watch_test.dart +++ b/packages/sqlite_async/test/watch_test.dart @@ -113,8 +113,10 @@ void main() { lastCount = count; } - // The number of read queries must not be greater than the number of writes overall. - expect(numberOfQueries, lessThanOrEqualTo(results.last.first['count'])); + // The number of read queries must not be greater than the number of + // writes overall, plus one for the initial stream emission. + expect(numberOfQueries, + lessThanOrEqualTo(results.last.first['count'] + 1)); DateTime? lastTime; for (var r in times) { @@ -253,5 +255,54 @@ void main() { done = true; } }); + + test('watch with transaction', () async { + final db = await testUtils.setupDatabase(path: path); + await createTables(db); + + const baseTime = 20; + + const throttleDuration = Duration(milliseconds: baseTime); + // delay must be bigger than throttleDuration, and bigger + // than any internal throttles. + const delay = Duration(milliseconds: baseTime * 3); + + final stream = db.watch('SELECT count() AS count FROM assets', + throttle: throttleDuration); + + List counts = []; + + final subscription = stream.listen((e) { + counts.add(e.first['count']); + }); + await Future.delayed(delay); + + await db.writeTransaction((tx) async { + await tx.execute('INSERT INTO assets(make) VALUES (?)', ['test1']); + await Future.delayed(delay); + await tx.execute('INSERT INTO assets(make) VALUES (?)', ['test2']); + await Future.delayed(delay); + }); + await Future.delayed(delay); + + await subscription.cancel(); + + expect( + counts, + equals([ + // one event when starting the subscription + 0, + // one event after the transaction + 2 + ])); + + // Other observed results (failure scenarios): + // [0, 0, 0]: The watch is triggered during the transaction + // and executes concurrently with the transaction. + // [0, 2, 2]: The watch is triggered during the transaction, + // but executes after the transaction (single connection). + // [0]: No updates triggered. + // [2, 2]: Timing issue? + }); }); } diff --git a/packages/sqlite_async/test/web/watch_test.dart b/packages/sqlite_async/test/web/watch_test.dart index 50bbae0..c6757b9 100644 --- a/packages/sqlite_async/test/web/watch_test.dart +++ b/packages/sqlite_async/test/web/watch_test.dart @@ -1,4 +1,6 @@ @TestOn('browser') +library; + import 'package:sqlite_async/sqlite_async.dart'; import 'package:test/test.dart'; diff --git a/packages/sqlite_async/test/web/web_mutex_test.dart b/packages/sqlite_async/test/web/web_mutex_test.dart new file mode 100644 index 0000000..8eeefcf --- /dev/null +++ b/packages/sqlite_async/test/web/web_mutex_test.dart @@ -0,0 +1,29 @@ +import 'package:sqlite_async/sqlite_async.dart'; +import 'package:test/test.dart'; + +import '../utils/test_utils_impl.dart'; + +final testUtils = TestUtils(); + +void main() { + group('Web Mutex Tests', () { + test('Web should share locking with identical identifiers', () async { + final m1 = Mutex(identifier: 'sync'); + final m2 = Mutex(identifier: 'sync'); + + final results = []; + final p1 = m1.lock(() async { + results.add(1); + }); + + final p2 = m2.lock(() async { + results.add(2); + }); + + await p1; + await p2; + // It should be correctly ordered as if it was the same mutex + expect(results, equals([1, 2])); + }); + }); +} diff --git a/scripts/sqlite3_wasm_download.dart b/scripts/sqlite3_wasm_download.dart index 28d91b4..9716eee 100644 --- a/scripts/sqlite3_wasm_download.dart +++ b/scripts/sqlite3_wasm_download.dart @@ -1,8 +1,10 @@ /// Downloads sqlite3.wasm +library; + import 'dart:io'; final sqliteUrl = - 'https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3-2.4.3/sqlite3.wasm'; + 'https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3-2.8.0/sqlite3.wasm'; void main() async { // Create assets directory if it doesn't exist