Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions packages/sqflite_watchos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,37 @@ physical watch.
`continueOnError`, transactions with transaction ids, database files and
bytes. `lib/src/handler.dart` is adapted from sqflite_common_ffi's
`sqflite_ffi_impl.dart` and carries its BSD-2-Clause notice.
* SQL runs on a background isolate, as sqflite_common_ffi's
`databaseFactoryFfi` runs it, so a long query does not hold up a frame
(#20). One isolate, started by the first call and shared by every
database, holds the open databases, their transactions and cursors. Each
isolate that uses sqflite gets its own, which closes its databases and
ends when that isolate ends, and keeps that isolate alive only while an
answer is due, so the isolate can still end on its own. A
`SqfliteDatabaseException` crosses back as its message, details, result
code and `transactionClosed`, so sqflite_common sees the same error; a
file error or an `ArgumentError` keeps its type. `lib/src/worker.dart` is
adapted from sqflite_common_ffi's `isolate_io.dart` and `method_call.dart`
and carries its BSD-2-Clause notice.
* A value that cannot be sent to the background isolate, such as a port or
the `Future` of a forgotten `await`, fails the whole call before it is
sent, as a method channel's codec fails it on iOS: a batch runs none of
its operations, and `continueOnError` cannot apply. The error's details
hold the call's SQL and arguments but not its database; a batch's error
has no details.
* Arguments bind as on iOS: a `bool` as 1 or 0, a `Uint8List` or plain
`List<int>` as a blob.
* A connection waits up to 2 seconds for another connection's lock, FMDB's
default on iOS.
default on iOS. The wait blocks the background isolate, not the UI. Two
connections to one file opened from the same isolate share its background
isolate, so they cannot wait each other out.
* An in-memory database is never a single instance.
* `SqfliteWatchos` extends `SqflitePlatform`; `registerWith` makes its
`databaseFactoryWatchos` sqflite's default on watchOS.
`databaseFactoryWatchos` sqflite's default on watchOS, and binds nothing
until the first call.
* Test hooks: `SqfliteWatchos.libraryPathOverride`, the path of a SQLite
library for the background isolate to open, and `databasesPathOverride`.
`resetForTesting` closes the isolate's databases and stops it.
* `sqflite_common: ">=2.5.14 <2.6.0"`: 2.5.14 fixes a hang when a cursor is
closed inside a transaction, and the `src/` imports stop below the next
minor.
Expand Down
23 changes: 19 additions & 4 deletions packages/sqflite_watchos/PORTING_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
|---|---|
| Implementation | ✅ Working (system SQLite over FFI) |
| watchOS capability | Full — every method of sqflite's protocol. The deprecated `Sqflite.*` debug helpers call the method channel directly and throw (see the README). Not yet checked on a physical watch |
| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API and the protocol below it |
| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (14 tests), against the watch's own `libsqlite3`. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits |
| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API on the background isolate, the protocol below it, and the isolate itself (start, order, errors, stopping, and its end with the isolate that started it) |
| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (Series 12, 46 mm; 14 tests), against the watch's own `libsqlite3`, with SQL on the background isolate (2026-10-30). Upstream's example (PR #37) also ran its "Raw tests" page there with no failure in the console. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits |
| Internal unified demo | not in the internal test app yet |

Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS.
Expand All @@ -19,6 +19,7 @@ Generated by `flutter-watchos plugin port` on 2026-10-19.

Source: `sqflite_darwin` 2.4.4+1
Protocol handler adapted from: `sqflite_common_ffi` 2.4.4, `lib/src/sqflite_ffi_impl.dart` (BSD-2-Clause, Alexandre Roux Tekartik)
Background isolate adapted from: `sqflite_common_ffi` 2.4.5, `lib/src/isolate_io.dart` and `lib/src/method_call.dart` (BSD-2-Clause, Alexandre Roux Tekartik)
Base platform: ios (Objective-C)
Output: `./sqflite_watchos`

Expand All @@ -36,8 +37,8 @@ package keeps none of that native code:
Each connection waits up to 2 seconds for another connection's lock,
FMDB's default on iOS. On the watch the functions resolve from the
process, because `watchos/Package.swift` links the system `libsqlite3`;
host tests open the host's system library instead, so the same code runs
real SQL there.
host tests have the background isolate open the host's system library by
path instead, so the same code runs real SQL there.
- `lib/src/handler.dart` answers sqflite's method protocol: the same
methods, arguments and results the method channel carries on iOS. It is
adapted from sqflite_common_ffi's `lib/src/sqflite_ffi_impl.dart`, the
Expand All @@ -46,6 +47,20 @@ package keeps none of that native code:
the open transaction waits for it), `transactionClosed` when SQLite rolls a
transaction back, paged cursors, batches with `continueOnError`, and
single-instance databases, never for `:memory:`, as in sqflite_darwin.
- `lib/src/worker.dart` runs the handler on a background isolate, as
sqflite_common_ffi's `databaseFactoryFfi` does, where sqflite_darwin gives
each database a dispatch queue of its own. The first call starts the
isolate, and every database shares it. The handler and what it holds
(databases, transactions, cursors) live there; each call crosses as a
message, and a `SqfliteDatabaseException` crosses back as its message,
details, result code and `transactionClosed`. The isolate closes its
databases and ends when the isolate that started it ends, and keeps that
isolate alive only while an answer is due. The 2-second lock wait blocks
the background isolate, not the UI; two connections to one file opened
from the same isolate share it, so they cannot wait each other out (see
the README). It is adapted from
sqflite_common_ffi's `lib/src/isolate_io.dart` and `lib/src/method_call.dart`,
and carries its copyright notice.
- `watchos/Classes/sqflite_watchos_ffi.m` has two helpers: the Documents
directory, and `sqlite3_libversion()`, which also keeps `libsqlite3` linked.
- sqflite federates through sqflite_common's default factory. `SqfliteWatchos`
Expand Down
27 changes: 20 additions & 7 deletions packages/sqflite_watchos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,23 @@ await db.insert('lap', <String, Object?>{'at': DateTime.now().millisecondsSinceE
| `readDatabaseBytes` / `writeDatabaseBytes` | supported |
| `DatabaseException` helpers | `isUniqueConstraintError`, `isNoSuchTableError`, `isSyntaxError`, `isOpenFailedError`, `isReadOnlyError`, `isDatabaseClosedError`; `getResultCode()` is SQLite's extended result code |

SQL runs on the isolate that calls it, synchronously inside each `Future`.
On iOS sqflite runs it on a native background thread; on the watch a long
query holds up the frame it runs in, so keep heavy work small or move it to
an isolate of its own.
SQL runs on a background isolate, as `sqflite_common_ffi` runs it on
desktop, so a long query does not hold up a frame. The first call starts
the isolate, and every database shares it: the open databases, their
transactions and their cursors live there, and each call reaches it as a
message, with its arguments and results copied. Each isolate that uses
sqflite gets a background isolate of its own, which closes its databases
and ends when that isolate ends. Errors come back intact, as a
`DatabaseException` with SQLite's message and result code.

The 2-second wait for another connection's lock blocks that isolate, not
the UI, and calls to every other database wait behind it. Two connections
to one file opened from the same isolate cannot wait each other out: the
background isolate that would commit the first is the one waiting, so the
second waits the full 2 seconds and fails with `SQLITE_BUSY` (result code
5). Keep one connection per file, as sqflite's default
`singleInstance: true` does. On iOS each database has a queue of its own,
so there the second connection gets the lock once the first commits.

`SqfliteWatchos.sqliteVersion` reports the system SQLite's version.

Expand All @@ -72,6 +85,6 @@ are accepted and ignored.

## License

BSD-2-Clause, as upstream sqflite's. `lib/src/handler.dart` is adapted from
sqflite_common_ffi and carries its copyright notice. See `LICENSE` for the
full text.
BSD-2-Clause, as upstream sqflite's. `lib/src/handler.dart` and
`lib/src/worker.dart` are adapted from sqflite_common_ffi and carry its
copyright notice. See `LICENSE` for the full text.
72 changes: 41 additions & 31 deletions packages/sqflite_watchos/lib/sqflite_watchos.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
//
// Method-channel plugins are not supported on watchOS, so sqflite's method
// protocol is answered in Dart (`src/handler.dart`) over the system SQLite's
// C API (`src/sqlite3.dart`). sqflite federates through sqflite_common's
// default database factory: `registerWith` sets it, as sqflite_darwin, which
// also extends SqflitePlatform, does with its method-channel factory.
// C API (`src/sqlite3.dart`), on a background isolate (`src/worker.dart`).
// sqflite federates through sqflite_common's default database factory:
// `registerWith` sets it, as sqflite_darwin, which also extends
// SqflitePlatform, does with its method-channel factory.

import 'dart:ffi';
import 'dart:io';

import 'package:ffi/ffi.dart';
// ignore: implementation_imports
Expand All @@ -20,34 +20,37 @@ import 'package:sqflite_common/src/mixin/factory.dart'
import 'package:sqflite_common/sqflite.dart';
import 'package:sqflite_platform_interface/sqflite_platform_interface.dart';

import 'src/handler.dart';
import 'src/sqlite3.dart';
import 'src/worker.dart';

/// watchOS implementation of `sqflite`.
class SqfliteWatchos extends SqflitePlatform {
/// Test hook: the library SQLite's C API resolves from, instead of the
/// process. Host tests pass the host's system SQLite.
static DynamicLibrary? libraryOverride;
/// Test hook: the path of the SQLite library the background isolate
/// opens, instead of resolving SQLite's C API from the process. Host tests
/// pass the host's system SQLite. It is a path because a [DynamicLibrary]
/// cannot be sent to another isolate.
static String? libraryPathOverride;

/// Test hook: where relative database paths live, instead of the app's
/// Documents directory.
static String? databasesPathOverride;

static SqfliteWatchosWorker? _worker;
static DatabaseFactory? _factory;

/// The database factory that answers sqflite's calls on the watch, as
/// [SqflitePlatform.databaseFactoryMethodChannel] answers them on iOS.
///
/// Its calls run on a background isolate, which the first call starts and
/// every database the factory opens shares. Each isolate that uses the
/// factory has its own, which ends with it.
static DatabaseFactory get databaseFactoryWatchos => _factory ??= () {
final DynamicLibrary library =
libraryOverride ?? DynamicLibrary.process();
final SqfliteWatchosHandler handler = SqfliteWatchosHandler(
Sqlite3Library(library),
databasesPath: databasesPathOverride ?? _databasesPath(library),
platformVersion: 'watchOS ${Platform.operatingSystemVersion}',
final SqfliteWatchosWorker worker = _worker = SqfliteWatchosWorker(
libraryPath: libraryPathOverride,
databasesPath: databasesPathOverride,
);
return buildDatabaseFactory(
tag: 'watchos',
invokeMethod: handler.handle,
invokeMethod: worker.invoke,
);
}();

Expand All @@ -58,20 +61,27 @@ class SqfliteWatchos extends SqflitePlatform {
}

/// Forgets the factory so the next use builds a new one, for tests that
/// change [libraryOverride] or [databasesPathOverride].
static void resetForTesting() => _factory = null;

static String _databasesPath(DynamicLibrary library) => library
.lookupFunction<Pointer<Utf8> Function(), Pointer<Utf8> Function()>(
'sqflite_watchos_databases_path')()
.toDartString();
/// change [libraryPathOverride] or [databasesPathOverride].
///
/// The old factory's isolate answers the calls already made, closes every
/// database it has open and ends. The future completes once it has ended.
/// Calls on the old factory then fail.
static Future<void> resetForTesting() {
final SqfliteWatchosWorker? worker = _worker;
_worker = null;
_factory = null;
return worker?.stop() ?? Future<void>.value();
}

/// The version of the SQLite the watch runs, for example `3.46.1`.
static String get sqliteVersion =>
(libraryOverride ?? DynamicLibrary.process())
.lookupFunction<Pointer<Utf8> Function(), Pointer<Utf8> Function()>(
libraryOverride == null
? 'sqflite_watchos_sqlite_version'
: 'sqlite3_libversion')()
.toDartString();
/// The version of the SQLite the watch runs, for example `3.46.1`. It is
/// read on the calling isolate.
static String get sqliteVersion {
final String? path = libraryPathOverride;
return (path == null ? DynamicLibrary.process() : DynamicLibrary.open(path))
.lookupFunction<Pointer<Utf8> Function(), Pointer<Utf8> Function()>(
path == null
? 'sqflite_watchos_sqlite_version'
: 'sqlite3_libversion')()
.toDartString();
}
}
13 changes: 11 additions & 2 deletions packages/sqflite_watchos/lib/src/handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
//
// sqflite_common drives every platform through one call,
// `invokeMethod(method, arguments)`: on iOS and Android it crosses a method
// channel, here it lands in [SqfliteWatchosHandler.handle]. The methods and
// the shape of their arguments and results are sqflite's own.
// channel, here it crosses to a background isolate (worker.dart) and lands
// in [SqfliteWatchosHandler.handle]. The methods and the shape of their
// arguments and results are sqflite's own.

import 'dart:async';
import 'dart:io';
Expand Down Expand Up @@ -273,6 +274,14 @@ class SqfliteWatchosHandler {
db.close();
}

/// Closes every open database, as `closeDatabase` closes one: what is
/// queued on each fails. The background isolate calls it before it ends.
void closeAll() {
for (final _Database db in _databases.values.toList()) {
_forget(db);
}
}

void _delete(String path) {
final _Database? open = _singleInstances[path];
if (open != null) {
Expand Down
4 changes: 2 additions & 2 deletions packages/sqflite_watchos/lib/src/sqlite3.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// The part of SQLite's C API this package uses, bound over dart:ffi.
//
// On the watch these resolve from the process: watchOS ships libsqlite3 and
// the CLI links it. Host tests pass the macOS system library instead, so the
// same code runs real SQL there.
// the CLI links it. Host tests open the host's system library instead, so
// the same code runs real SQL there.

import 'dart:convert';
import 'dart:ffi';
Expand Down
Loading
Loading