Merge branch 'list-wasm-databases' into develop

This commit is contained in:
Simon Binder 2023-08-10 17:36:12 +02:00
commit 08fa63069c
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
11 changed files with 660 additions and 272 deletions

View File

@ -13,7 +13,6 @@ import 'dart:async';
import 'dart:html';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:drift/remote.dart';
import 'package:drift/wasm.dart';
@ -24,8 +23,8 @@ import 'package:sqlite3/wasm.dart';
import 'broadcast_stream_queries.dart';
import 'channel.dart';
import 'wasm_setup/protocol.dart';
import 'wasm_setup/shared.dart';
import 'wasm_setup/protocol.dart';
/// Whether the `crossOriginIsolated` JavaScript property is true in the current
/// context.
@ -41,28 +40,53 @@ bool get supportsWorkers => hasProperty(globalThis, 'Worker');
class WasmDatabaseOpener {
final Uri sqlite3WasmUri;
final Uri driftWorkerUri;
final String databaseName;
FutureOr<Uint8List?> Function()? initializeDatabase;
final String? databaseName;
final Set<MissingBrowserFeature> missingFeatures = {};
final List<WasmStorageImplementation> availableImplementations = [
WasmStorageImplementation.inMemory,
];
final Set<ExistingDatabase> existingDatabases = {};
bool _existsInIndexedDb = false;
bool _existsInOpfs = false;
_DriftWorker? _sharedWorker, _dedicatedWorker;
MessagePort? _sharedWorker;
Worker? _dedicatedWorker;
WasmDatabaseOpener(
this.sqlite3WasmUri,
this.driftWorkerUri,
this.databaseName,
);
WasmDatabaseOpener({
required this.sqlite3WasmUri,
required this.driftWorkerUri,
required this.databaseName,
this.initializeDatabase,
});
RequestCompatibilityCheck _createCompatibilityCheck() {
return RequestCompatibilityCheck(databaseName ?? 'driftCompatibilityCheck');
}
Future<void> probe() async {
void _handleCompatibilityResult(CompatibilityResult result) {
missingFeatures.addAll(result.missingFeatures);
final databaseName = this.databaseName;
// Note that existingDatabases are only sent from workers shipped with drift
// 2.11 or later. Later drift versions need to be able to talk to newer
// workers though.
if (result.existingDatabases.isNotEmpty) {
existingDatabases.addAll(result.existingDatabases);
}
if (databaseName != null) {
// If this opener has been created for WasmDatabase.open, we have a
// database name and can interpret the opfsExists and indexedDbExists
// fields we're getting from older workers accordingly.
if (result.opfsExists) {
existingDatabases.add((WebStorageApi.opfs, databaseName));
}
if (result.indexedDbExists) {
existingDatabases.add((WebStorageApi.indexedDb, databaseName));
}
}
}
Future<WasmProbeResult> probe() async {
try {
await _probeShared();
} on Object {
@ -72,99 +96,167 @@ class WasmDatabaseOpener {
try {
await _probeDedicated();
} on Object {
_dedicatedWorker?.terminate();
_dedicatedWorker?.close();
_dedicatedWorker = null;
}
if (_dedicatedWorker == null) {
// Something is wrong with web workers, let's see if we can get things
// running without a worker.
if (await checkIndexedDbSupport()) {
availableImplementations.add(WasmStorageImplementation.unsafeIndexedDb);
_existsInIndexedDb = await checkIndexedDbExists(databaseName);
return _ProbeResult(availableImplementations, existingDatabases.toList(),
missingFeatures, this);
}
Future<void> _probeDedicated() async {
if (supportsWorkers) {
final dedicatedWorker = _dedicatedWorker =
_DriftWorker.dedicated(Worker(driftWorkerUri.toString()));
_createCompatibilityCheck().sendTo(dedicatedWorker.send);
final status = await dedicatedWorker.workerMessages.nextNoError
as DedicatedWorkerCompatibilityResult;
_handleCompatibilityResult(status);
if (status.supportsNestedWorkers &&
status.canAccessOpfs &&
status.supportsSharedArrayBuffers) {
availableImplementations.add(WasmStorageImplementation.opfsLocks);
}
if (status.supportsIndexedDb) {
availableImplementations.add(WasmStorageImplementation.unsafeIndexedDb);
}
} else {
missingFeatures.add(MissingBrowserFeature.dedicatedWorkers);
}
}
Future<WasmDatabaseResult> open() async {
await probe();
Future<void> _probeShared() async {
if (supportsSharedWorkers) {
final sharedWorker =
SharedWorker(driftWorkerUri.toString(), 'drift worker');
final port = sharedWorker.port!;
final shared = _sharedWorker = _DriftWorker.shared(sharedWorker, port);
// If we have an existing database in storage, we want to keep using that
// format to avoid data loss (e.g. after a browser update that enables a
// otherwise preferred storage implementation). In the future, we might want
// to consider migrating between storage implementations as well.
if (_existsInIndexedDb &&
(availableImplementations
.contains(WasmStorageImplementation.sharedIndexedDb) ||
availableImplementations
.contains(WasmStorageImplementation.unsafeIndexedDb))) {
availableImplementations.removeWhere((element) =>
element != WasmStorageImplementation.sharedIndexedDb &&
element != WasmStorageImplementation.unsafeIndexedDb);
} else if (_existsInOpfs &&
(availableImplementations
.contains(WasmStorageImplementation.opfsShared) ||
availableImplementations
.contains(WasmStorageImplementation.opfsLocks))) {
availableImplementations.removeWhere((element) =>
element != WasmStorageImplementation.opfsShared &&
element != WasmStorageImplementation.opfsLocks);
// First, the shared worker will tell us which features it supports.
_createCompatibilityCheck().sendToPort(port);
final sharedFeatures = await shared.workerMessages.nextNoError
as SharedWorkerCompatibilityResult;
_handleCompatibilityResult(sharedFeatures);
// Prefer to use the shared worker to host the database if it supports the
// necessary APIs.
if (sharedFeatures.canSpawnDedicatedWorkers &&
sharedFeatures.dedicatedWorkersCanUseOpfs) {
availableImplementations.add(WasmStorageImplementation.opfsShared);
}
if (sharedFeatures.canUseIndexedDb) {
availableImplementations.add(WasmStorageImplementation.sharedIndexedDb);
}
} else {
missingFeatures.add(MissingBrowserFeature.sharedWorkers);
}
}
}
// Enum values are ordered by preferrability, so just pick the best option
// left.
availableImplementations.sortBy<num>((element) => element.index);
return await _connect(availableImplementations.firstOrNull ??
WasmStorageImplementation.inMemory);
final class _DriftWorker {
final AbstractWorker worker;
/// The message port to communicate with the worker, if it's a shared worker.
final MessagePort? portForShared;
final StreamQueue<WasmInitializationMessage> workerMessages;
_DriftWorker.dedicated(Worker this.worker)
: portForShared = null,
workerMessages =
StreamQueue(_readMessages(worker.onMessage, worker.onError));
_DriftWorker.shared(SharedWorker this.worker, this.portForShared)
: workerMessages =
StreamQueue(_readMessages(worker.port!.onMessage, worker.onError));
void send(Object? msg, [List<Object>? transfer]) {
switch (worker) {
case final Worker worker:
worker.postMessage(msg, transfer);
case SharedWorker():
portForShared!.postMessage(msg, transfer);
}
}
/// Opens a database with the given [storage] implementation, bypassing the
/// feature detection. Must be called after [probe].
Future<WasmDatabaseResult> openWith(WasmStorageImplementation storage) async {
return await _connect(storage);
}
void close() {
workerMessages.cancel();
Future<WasmDatabaseResult> _connect(WasmStorageImplementation storage) async {
switch (worker) {
case final Worker dedicated:
dedicated.terminate();
case SharedWorker():
portForShared!.close();
}
}
}
final class _ProbeResult implements WasmProbeResult {
@override
final List<WasmStorageImplementation> availableStorages;
@override
final List<ExistingDatabase> existingDatabases;
@override
final Set<MissingBrowserFeature> missingFeatures;
final WasmDatabaseOpener opener;
_ProbeResult(
this.availableStorages,
this.existingDatabases,
this.missingFeatures,
this.opener,
);
@override
Future<DatabaseConnection> open(
WasmStorageImplementation implementation,
String name, {
FutureOr<Uint8List?> Function()? initializeDatabase,
}) async {
final channel = MessageChannel();
final initializer = initializeDatabase;
final initChannel = initializer != null ? MessageChannel() : null;
final local = channel.port1.channel();
final message = ServeDriftDatabase(
sqlite3WasmUri: sqlite3WasmUri,
sqlite3WasmUri: opener.sqlite3WasmUri,
port: channel.port2,
storage: storage,
databaseName: databaseName,
storage: implementation,
databaseName: name,
initializationPort: initChannel?.port2,
);
final sharedWorker = _sharedWorker;
final dedicatedWorker = _dedicatedWorker;
final sharedWorker = opener._sharedWorker;
final dedicatedWorker = opener._dedicatedWorker;
switch (storage) {
switch (implementation) {
case WasmStorageImplementation.opfsShared:
case WasmStorageImplementation.sharedIndexedDb:
// These are handled by the shared worker, so we can close the dedicated
// worker used for feature detection.
dedicatedWorker?.terminate();
message.sendToPort(sharedWorker!);
// Forward connection request to shared worker.
message.sendTo(sharedWorker!.send);
case WasmStorageImplementation.opfsLocks:
case WasmStorageImplementation.unsafeIndexedDb:
sharedWorker?.close();
if (dedicatedWorker != null) {
message.sendToWorker(dedicatedWorker);
message.sendTo(dedicatedWorker.send);
} else {
// Workers seem to be broken, but we don't need them with this storage
// mode.
return _hostDatabaseLocally(
storage, await IndexedDbFileSystem.open(dbName: databaseName));
return _hostDatabaseLocally(implementation,
await IndexedDbFileSystem.open(dbName: name), initializeDatabase);
}
case WasmStorageImplementation.inMemory:
// Nothing works on this browser, so we'll fall back to an in-memory
// database.
return _hostDatabaseLocally(storage, InMemoryFileSystem());
return _hostDatabaseLocally(
implementation, InMemoryFileSystem(), initializeDatabase);
}
initChannel?.port1.onMessage.listen((event) async {
@ -181,7 +273,7 @@ class WasmDatabaseOpener {
});
var connection = await connectToRemoteAndInitialize(local);
if (storage == WasmStorageImplementation.opfsLocks) {
if (implementation == WasmStorageImplementation.opfsLocks) {
// We want stream queries to update for writes in other tabs. For the
// implementations backed by a shared worker, the worker takes care of
// that.
@ -193,20 +285,21 @@ class WasmDatabaseOpener {
connection = DatabaseConnection(
connection.executor,
connectionData: connection.connectionData,
streamQueries: BroadcastStreamQueryStore(databaseName),
streamQueries: BroadcastStreamQueryStore(name),
);
}
}
return WasmDatabaseResult(connection, storage, missingFeatures);
return connection;
}
/// Returns a database connection that doesn't use web workers.
Future<WasmDatabaseResult> _hostDatabaseLocally(
WasmStorageImplementation storage, VirtualFileSystem vfs) async {
final initializer = initializeDatabase;
final sqlite3 = await WasmSqlite3.loadFromUrl(sqlite3WasmUri);
Future<DatabaseConnection> _hostDatabaseLocally(
WasmStorageImplementation storage,
VirtualFileSystem vfs,
FutureOr<Uint8List?> Function()? initializer,
) async {
final sqlite3 = await WasmSqlite3.loadFromUrl(opener.sqlite3WasmUri);
sqlite3.registerVirtualFileSystem(vfs);
if (initializer != null) {
@ -220,75 +313,26 @@ class WasmDatabaseOpener {
}
}
return WasmDatabaseResult(
DatabaseConnection(
WasmDatabase(sqlite3: sqlite3, path: '/database'),
),
storage,
missingFeatures,
return DatabaseConnection(
WasmDatabase(sqlite3: sqlite3, path: '/database'),
);
}
Future<void> _probeShared() async {
if (supportsSharedWorkers) {
final sharedWorker =
SharedWorker(driftWorkerUri.toString(), 'drift worker');
final port = _sharedWorker = sharedWorker.port!;
@override
Future<void> deleteDatabase(ExistingDatabase database) async {
switch (database.$1) {
case WebStorageApi.indexedDb:
await deleteDatabaseInIndexedDb(database.$2);
case WebStorageApi.opfs:
final dedicated = opener._dedicatedWorker;
if (dedicated != null) {
DeleteDatabase(database).sendTo(dedicated.send);
final sharedMessages =
StreamQueue(_readMessages(port.onMessage, sharedWorker.onError));
// First, the shared worker will tell us which features it supports.
RequestCompatibilityCheck(databaseName).sendToPort(port);
final sharedFeatures =
await sharedMessages.nextNoError as SharedWorkerCompatibilityResult;
await sharedMessages.cancel();
missingFeatures.addAll(sharedFeatures.missingFeatures);
_existsInOpfs |= sharedFeatures.opfsExists;
_existsInIndexedDb |= sharedFeatures.indexedDbExists;
// Prefer to use the shared worker to host the database if it supports the
// necessary APIs.
if (sharedFeatures.canSpawnDedicatedWorkers &&
sharedFeatures.dedicatedWorkersCanUseOpfs) {
availableImplementations.add(WasmStorageImplementation.opfsShared);
}
if (sharedFeatures.canUseIndexedDb) {
availableImplementations.add(WasmStorageImplementation.sharedIndexedDb);
}
} else {
missingFeatures.add(MissingBrowserFeature.sharedWorkers);
}
}
Future<void> _probeDedicated() async {
if (supportsWorkers) {
final dedicatedWorker =
_dedicatedWorker = Worker(driftWorkerUri.toString());
RequestCompatibilityCheck(databaseName).sendToWorker(dedicatedWorker);
final workerMessages = StreamQueue(
_readMessages(dedicatedWorker.onMessage, dedicatedWorker.onError));
final status = await workerMessages.nextNoError
as DedicatedWorkerCompatibilityResult;
missingFeatures.addAll(status.missingFeatures);
_existsInOpfs |= status.opfsExists;
_existsInIndexedDb |= status.indexedDbExists;
if (status.supportsNestedWorkers &&
status.canAccessOpfs &&
status.supportsSharedArrayBuffers) {
availableImplementations.add(WasmStorageImplementation.opfsLocks);
}
if (status.supportsIndexedDb) {
availableImplementations.add(WasmStorageImplementation.unsafeIndexedDb);
}
} else {
missingFeatures.add(MissingBrowserFeature.dedicatedWorkers);
await dedicated.workerMessages.nextNoError;
} else {
throw StateError(
'No dedicated worker available to delete OPFS database');
}
}
}
}

View File

@ -3,11 +3,9 @@
import 'dart:async';
import 'dart:html';
import 'package:drift/wasm.dart';
import 'package:js/js_util.dart';
import 'package:sqlite3/wasm.dart';
// ignore: implementation_imports
import 'package:sqlite3/src/wasm/js_interop/file_system_access.dart';
import 'package:path/path.dart' as p;
import '../../utils/synchronized.dart';
import 'protocol.dart';
@ -48,32 +46,25 @@ class DedicatedDriftWorker {
});
final existingServer = _servers.servers[dbName];
var indexedDbExists = false, opfsExists = false;
final existingDatabases = <ExistingDatabase>[];
if (supportsOpfs) {
for (final database in await opfsDatabases()) {
existingDatabases.add((WebStorageApi.opfs, database));
if (database == dbName) {
opfsExists = true;
}
}
}
if (existingServer != null) {
indexedDbExists = existingServer.storage.isIndexedDbBased;
opfsExists = existingServer.storage.isOpfsBased;
} else {
if (supportsIndexedDb) {
indexedDbExists = await checkIndexedDbExists(dbName);
}
if (supportsOpfs) {
final storage = storageManager!;
final pathSegments = p.url.split(pathForOpfs(dbName));
var directory = await storage.directory;
opfsExists = true;
for (final segment in pathSegments) {
try {
directory = await directory.getDirectory(segment);
} on Object {
opfsExists = false;
break;
}
}
}
} else if (supportsIndexedDb) {
indexedDbExists = await checkIndexedDbExists(dbName);
}
DedicatedWorkerCompatibilityResult(
@ -84,6 +75,7 @@ class DedicatedDriftWorker {
hasProperty(globalThis, 'SharedArrayBuffer'),
opfsExists: opfsExists,
indexedDbExists: indexedDbExists,
existingDatabases: existingDatabases,
).sendToClient(self);
case ServeDriftDatabase():
_servers.serve(message);
@ -91,6 +83,22 @@ class DedicatedDriftWorker {
final worker = await VfsWorker.create(options);
self.postMessage(true);
await worker.start();
case DeleteDatabase(database: (final storage, final name)):
try {
switch (storage) {
case WebStorageApi.indexedDb:
await deleteDatabaseInIndexedDb(name);
case WebStorageApi.opfs:
await deleteDatabaseInOpfs(name);
}
// Send the request back to indicate a successful delete.
message.sendToClient(self);
} catch (e) {
WorkerError(e.toString()).sendToClient(self);
}
break;
default:
break;
}

View File

@ -1,6 +1,7 @@
// ignore_for_file: public_member_api_docs
import 'dart:html';
import 'dart:js';
import 'package:js/js_util.dart';
import 'package:sqlite3/wasm.dart';
@ -29,6 +30,7 @@ sealed class WasmInitializationMessage {
DedicatedWorkerCompatibilityResult.fromJsPayload(payload!),
SharedWorkerCompatibilityResult.type =>
SharedWorkerCompatibilityResult.fromJsPayload(payload!),
DeleteDatabase.type => DeleteDatabase.fromJsPayload(payload!),
_ => throw ArgumentError('Unknown type $type'),
};
}
@ -55,38 +57,66 @@ sealed class WasmInitializationMessage {
}
}
sealed class CompatibilityResult extends WasmInitializationMessage {
/// All existing databases.
///
/// This list is only reported by the drift worker shipped with drift 2.11.
/// When an older worker is used, only [indexedDbExists] and [opfsExists] can
/// be used to check whether the database exists.
final List<ExistingDatabase> existingDatabases;
final bool indexedDbExists;
final bool opfsExists;
Iterable<MissingBrowserFeature> get missingFeatures;
CompatibilityResult({
required this.existingDatabases,
required this.indexedDbExists,
required this.opfsExists,
});
}
/// A message used by the shared worker to report compatibility results.
///
/// It describes the features available from the shared worker, which the tab
/// can use to infer a desired storage implementation, or whether the shared
/// worker should be used at all.
final class SharedWorkerCompatibilityResult extends WasmInitializationMessage {
final class SharedWorkerCompatibilityResult extends CompatibilityResult {
static const type = 'SharedWorkerCompatibilityResult';
final bool canSpawnDedicatedWorkers;
final bool dedicatedWorkersCanUseOpfs;
final bool canUseIndexedDb;
final bool indexedDbExists;
final bool opfsExists;
SharedWorkerCompatibilityResult({
required this.canSpawnDedicatedWorkers,
required this.dedicatedWorkersCanUseOpfs,
required this.canUseIndexedDb,
required this.indexedDbExists,
required this.opfsExists,
required super.indexedDbExists,
required super.opfsExists,
required super.existingDatabases,
});
factory SharedWorkerCompatibilityResult.fromJsPayload(Object payload) {
final data = (payload as List).cast<bool>();
final asList = payload as List;
final asBooleans = asList.cast<bool>();
final List<ExistingDatabase> existingDatabases;
if (asList.length > 5) {
existingDatabases =
EncodeLocations.readFromJs(asList[5] as List<dynamic>);
} else {
existingDatabases = const [];
}
return SharedWorkerCompatibilityResult(
canSpawnDedicatedWorkers: data[0],
dedicatedWorkersCanUseOpfs: data[1],
canUseIndexedDb: data[2],
indexedDbExists: data[3],
opfsExists: data[4],
canSpawnDedicatedWorkers: asBooleans[0],
dedicatedWorkersCanUseOpfs: asBooleans[1],
canUseIndexedDb: asBooleans[2],
indexedDbExists: asBooleans[3],
opfsExists: asBooleans[4],
existingDatabases: existingDatabases,
);
}
@ -98,9 +128,11 @@ final class SharedWorkerCompatibilityResult extends WasmInitializationMessage {
canUseIndexedDb,
indexedDbExists,
opfsExists,
existingDatabases.encodeToJs(),
]);
}
@override
Iterable<MissingBrowserFeature> get missingFeatures sync* {
if (!canSpawnDedicatedWorkers) {
yield MissingBrowserFeature.dedicatedWorkersInSharedWorkers;
@ -183,6 +215,10 @@ final class ServeDriftDatabase extends WasmInitializationMessage {
final class RequestCompatibilityCheck extends WasmInitializationMessage {
static const type = 'RequestCompatibilityCheck';
/// The database name to check when reporting whether it exists already.
///
/// Older versions of the drif worker only support checking a single database
/// name. On newer workers, this field is ignored.
final String databaseName;
RequestCompatibilityCheck(this.databaseName);
@ -197,8 +233,7 @@ final class RequestCompatibilityCheck extends WasmInitializationMessage {
}
}
final class DedicatedWorkerCompatibilityResult
extends WasmInitializationMessage {
final class DedicatedWorkerCompatibilityResult extends CompatibilityResult {
static const type = 'DedicatedWorkerCompatibilityResult';
final bool supportsNestedWorkers;
@ -206,22 +241,24 @@ final class DedicatedWorkerCompatibilityResult
final bool supportsSharedArrayBuffers;
final bool supportsIndexedDb;
/// Whether an IndexedDb database under the desired name exists already.
final bool indexedDbExists;
/// Whether an OPFS database under the desired name exists already.
final bool opfsExists;
DedicatedWorkerCompatibilityResult({
required this.supportsNestedWorkers,
required this.canAccessOpfs,
required this.supportsSharedArrayBuffers,
required this.supportsIndexedDb,
required this.indexedDbExists,
required this.opfsExists,
required super.indexedDbExists,
required super.opfsExists,
required super.existingDatabases,
});
factory DedicatedWorkerCompatibilityResult.fromJsPayload(Object payload) {
final existingDatabases = <ExistingDatabase>[];
if (hasProperty(payload, 'existing')) {
existingDatabases
.addAll(EncodeLocations.readFromJs(getProperty(payload, 'existing')));
}
return DedicatedWorkerCompatibilityResult(
supportsNestedWorkers: getProperty(payload, 'supportsNestedWorkers'),
canAccessOpfs: getProperty(payload, 'canAccessOpfs'),
@ -230,6 +267,7 @@ final class DedicatedWorkerCompatibilityResult
supportsIndexedDb: getProperty(payload, 'supportsIndexedDb'),
indexedDbExists: getProperty(payload, 'indexedDbExists'),
opfsExists: getProperty(payload, 'opfsExists'),
existingDatabases: existingDatabases,
);
}
@ -244,10 +282,12 @@ final class DedicatedWorkerCompatibilityResult
object, 'supportsSharedArrayBuffers', supportsSharedArrayBuffers);
setProperty(object, 'indexedDbExists', indexedDbExists);
setProperty(object, 'opfsExists', opfsExists);
setProperty(object, 'existing', existingDatabases.encodeToJs());
sender.sendTyped(type, object);
}
@override
Iterable<MissingBrowserFeature> get missingFeatures sync* {
if (!canAccessOpfs) {
yield MissingBrowserFeature.fileSystemAccess;
@ -278,6 +318,55 @@ final class StartFileSystemServer extends WasmInitializationMessage {
}
}
final class DeleteDatabase extends WasmInitializationMessage {
static const type = 'DeleteDatabase';
final ExistingDatabase database;
DeleteDatabase(this.database);
factory DeleteDatabase.fromJsPayload(Object payload) {
final asList = payload as List<Object?>;
return DeleteDatabase((
WebStorageApi.byName[asList[0] as String]!,
asList[1] as String,
));
}
@override
void sendTo(PostMessage sender) {
sender.sendTyped(type, [database.$1.name, database.$2]);
}
}
extension EncodeLocations on List<ExistingDatabase> {
static List<ExistingDatabase> readFromJs(List<Object?> object) {
final existing = <ExistingDatabase>[];
for (final entry in object) {
existing.add((
WebStorageApi.byName[getProperty(entry as Object, 'l')]!,
getProperty(entry, 'n'),
));
}
return existing;
}
Object encodeToJs() {
final existing = JsArray<Object>();
for (final entry in this) {
final object = newObject<Object>();
setProperty(object, 'l', entry.$1.name);
setProperty(object, 'n', entry.$2);
existing.add(object);
}
return existing;
}
}
extension on PostMessage {
void sendTyped(String type, Object? payload, [List<Object>? transfer]) {
final object = newObject<Object>();

View File

@ -87,7 +87,7 @@ Future<bool> checkIndexedDbExists(String databaseName) async {
try {
final idb = getProperty<IdbFactory>(globalThis, 'indexedDB');
await idb.open(
final database = await idb.open(
databaseName,
// Current schema version used by the [IndexedDbFileSystem]
version: 1,
@ -100,6 +100,7 @@ Future<bool> checkIndexedDbExists(String databaseName) async {
);
indexedDbExists ??= true;
database.close();
} catch (_) {
// May throw due to us aborting in the upgrade callback.
}
@ -107,12 +108,55 @@ Future<bool> checkIndexedDbExists(String databaseName) async {
return indexedDbExists ?? false;
}
/// Deletes a database from IndexedDb if supported.
Future<void> deleteDatabaseInIndexedDb(String databaseName) async {
final idb = window.indexedDB;
if (idb != null) {
await idb.deleteDatabase(databaseName);
}
}
/// Constructs the path used by drift to store a database in the origin-private
/// section of the agent's file system.
String pathForOpfs(String databaseName) {
return 'drift_db/$databaseName';
}
/// Collects all drift OPFS databases.
Future<List<String>> opfsDatabases() async {
final storage = storageManager;
if (storage == null) return const [];
var directory = await storage.directory;
try {
directory = await directory.getDirectory('drift_db');
} on Object {
// The drift_db folder doesn't exist, so there aren't any databases.
return const [];
}
return [
await for (final entry in directory.list())
if (entry.isDirectory) entry.name,
];
}
/// Deletes the OPFS folder storing a database with the given [databaseName] if
/// such folder exists.
Future<void> deleteDatabaseInOpfs(String databaseName) async {
final storage = storageManager;
if (storage == null) return;
var directory = await storage.directory;
try {
directory = await directory.getDirectory('drift_db');
await directory.removeEntry(databaseName, recursive: true);
} on Object {
// fine, an error probably means that the database didn't exist in the first
// place.
}
}
/// Manages drift servers.
///
/// When using a shared worker, multiple clients may want to use different drift
@ -132,7 +176,8 @@ class DriftServerController {
final vfs = await switch (message.storage) {
WasmStorageImplementation.opfsShared =>
SimpleOpfsFileSystem.loadFromStorage(message.databaseName),
SimpleOpfsFileSystem.loadFromStorage(
pathForOpfs(message.databaseName)),
WasmStorageImplementation.opfsLocks =>
_loadLockedWasmVfs(message.databaseName),
WasmStorageImplementation.unsafeIndexedDb ||
@ -171,7 +216,7 @@ class DriftServerController {
Future<WasmVfs> _loadLockedWasmVfs(String databaseName) async {
// Create SharedArrayBuffers to synchronize requests
final options = WasmVfs.createOptions(
root: 'drift_db/$databaseName/',
root: pathForOpfs(databaseName),
);
final worker = Worker(Uri.base.toString());

View File

@ -76,6 +76,7 @@ class SharedDriftWorker {
canUseIndexedDb: canUseIndexedDb,
indexedDbExists: indexedDbExists,
opfsExists: false,
existingDatabases: const [],
);
} else {
final worker = _dedicatedWorker ??= Worker(Uri.base.toString());
@ -86,7 +87,12 @@ class SharedDriftWorker {
final completer = Completer<SharedWorkerCompatibilityResult>();
StreamSubscription? messageSubscription, errorSubscription;
void result(bool opfsAvailable, bool opfsExists, bool indexedDbExists) {
void result(
bool opfsAvailable,
bool opfsExists,
bool indexedDbExists,
List<ExistingDatabase> databases,
) {
if (!completer.isCompleted) {
completer.complete(SharedWorkerCompatibilityResult(
canSpawnDedicatedWorkers: true,
@ -94,6 +100,7 @@ class SharedDriftWorker {
canUseIndexedDb: canUseIndexedDb,
indexedDbExists: indexedDbExists,
opfsExists: opfsExists,
existingDatabases: databases,
));
messageSubscription?.cancel();
@ -110,11 +117,12 @@ class SharedDriftWorker {
compatibilityResult.canAccessOpfs,
compatibilityResult.opfsExists,
compatibilityResult.indexedDbExists,
compatibilityResult.existingDatabases,
);
});
errorSubscription = worker.onError.listen((event) {
result(false, false, false);
result(false, false, false, const []);
worker.terminate();
_dedicatedWorker = null;
});

View File

@ -4,6 +4,8 @@
/// integration tests on a Dart VM (`extras/integration_tests/web_wasm`).
library;
import 'dart:async';
import 'package:drift/drift.dart';
/// The storage implementation used by the `drift` and `sqlite3` packages to
@ -80,6 +82,19 @@ enum WasmStorageImplementation {
inMemory,
}
/// The storage API used by drift to store a database.
enum WebStorageApi {
/// The database is stored in the origin-private section of the user's file
/// system via the FileSystem Access API.
opfs,
/// The database is stored in IndexedDb.
indexedDb;
/// Cached [EnumByName.asNameMap] for [values].
static final byName = WebStorageApi.values.asNameMap();
}
/// An enumeration of features not supported by the current browsers.
///
/// While this information may not be useful to end users, it can be used to
@ -118,8 +133,64 @@ enum MissingBrowserFeature {
sharedArrayBuffers,
}
/// The result of opening a WASM database.
class WasmDatabaseResult {
/// Information about an existing web database, consisting of its
/// storage API ([WebStorageApi]) and its name.
typedef ExistingDatabase = (WebStorageApi, String);
/// The result of probing the current browser for wasm compatibility.
///
/// This reports available storage implementations ([availableStorages]) and
/// [missingFeatures] that contributed to some storage implementations not being
/// available.
///
/// In addition, [existingDatabases] reports a list of existing databases. Note
/// that databases stored in IndexedDb can't be listed reliably. Only databases
/// with the name given in [WasmDatabase.probe] are listed. Databases stored in
/// OPFS are always listed.
abstract interface class WasmProbeResult {
/// All available [WasmStorageImplementation]s supported by the current
/// browsing context.
///
/// Depending on the features available in the browser your app runs on and
/// whether your app is served with the required headers for shared array
/// buffers, different implementations might be available.
///
/// You can see the [WasmStorageImplementation]s and
/// [the web documentation](https://drift.simonbinder.eu/web/#storages) to
/// learn more about which implementations drift can use.
List<WasmStorageImplementation> get availableStorages;
/// For every storage found, drift also reports existing drift databases.
List<ExistingDatabase> get existingDatabases;
/// An enumeration of missing browser features probed by drift.
///
/// Missing browser features limit the available storage implementations.
Set<MissingBrowserFeature> get missingFeatures;
/// Opens a connection to a database via the chosen [implementation].
///
/// When this database doesn't exist, [initializeDatabase] is invoked to
/// optionally return the initial bytes of the database.
Future<DatabaseConnection> open(
WasmStorageImplementation implementation,
String name, {
FutureOr<Uint8List?> Function()? initializeDatabase,
});
/// Deletes an [ExistingDatabase] from storage.
///
/// This method should not be called while a connection to the database is
/// opened.
///
/// This method is only supported when using the drift worker shipped with the
/// drift 2.11 release or later. This method will not work when using an older
/// worker.
Future<void> deleteDatabase(ExistingDatabase database);
}
/// The result of opening a WASM database with default options.
final class WasmDatabaseResult {
/// The drift database connection to pass to the [GeneratedDatabase.new]
/// constructor of your database class to use the opened database.
final DatabaseConnection resolvedExecutor;

View File

@ -10,11 +10,12 @@ import 'dart:async';
import 'dart:html';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:drift/src/web/wasm_setup.dart';
import 'package:sqlite3/wasm.dart';
import 'backends.dart';
import 'src/sqlite3/database.dart';
import 'src/web/wasm_setup.dart';
import 'src/web/wasm_setup/dedicated_worker.dart';
import 'src/web/wasm_setup/shared_worker.dart';
import 'src/web/wasm_setup/types.dart';
@ -90,13 +91,87 @@ class WasmDatabase extends DelegatedDatabase {
required Uri sqlite3Uri,
required Uri driftWorkerUri,
FutureOr<Uint8List?> Function()? initializeDatabase,
}) {
return WasmDatabaseOpener(
databaseName: databaseName,
sqlite3WasmUri: sqlite3Uri,
}) async {
final probed = await probe(
sqlite3Uri: sqlite3Uri,
driftWorkerUri: driftWorkerUri,
initializeDatabase: initializeDatabase,
).open();
databaseName: databaseName,
);
// If we have an existing database in storage, we want to keep using that
// format to avoid data loss (e.g. after a browser update that enables a
// otherwise preferred storage implementation). In the future, we might want
// to consider migrating between storage implementations as well.
final availableImplementations = probed.availableStorages.toList();
checkExisting:
for (final (location, name) in probed.existingDatabases) {
if (name == databaseName) {
final implementationsForStorage = switch (location) {
WebStorageApi.indexedDb => const [
WasmStorageImplementation.sharedIndexedDb,
WasmStorageImplementation.unsafeIndexedDb
],
WebStorageApi.opfs => const [
WasmStorageImplementation.opfsShared,
WasmStorageImplementation.opfsLocks,
],
};
// If any of the implementations for this location is still availalable,
// we want to use it instead of another location.
if (implementationsForStorage.any(availableImplementations.contains)) {
availableImplementations
.removeWhere((i) => !implementationsForStorage.contains(i));
break checkExisting;
}
}
}
// Enum values are ordered by preferrability, so just pick the best option
// left.
availableImplementations.sortBy<num>((element) => element.index);
final bestImplementation = availableImplementations.firstOrNull ??
WasmStorageImplementation.inMemory;
final connection = await probed.open(bestImplementation, databaseName);
return WasmDatabaseResult(
connection, bestImplementation, probed.missingFeatures);
}
/// Probes for:
///
/// - available storage implementations based on supported web APIs.
/// - APIs not currently supported by the browser.
/// - existing drift databases in the current browsing context.
///
/// This information can be used to control whether to open a drift database,
/// or whether the current browser is unsuitable for the persistence
/// requirements of your app.
/// For most apps, using [open] directly is easier. It calls [probe]
/// internally and uses the best storage implementation available.
///
/// The [databaseName] option is not strictly required. But drift can't list
/// databases stored in IndexedDb, they are not part of
/// [WasmProbeResult.existingDatabases] by default. This is because drift
/// databases can't be distinguished from other IndexedDb databases without
/// opening them, which might disturb the running operation of them. When a
/// [databaseName] is passed, drift will explicitly probe whether a database
/// with that name exists in IndexedDb and whether it is a drift database.
/// Drift is always able to list databases stored in OPFS, regardless of
/// whether [databaseName] is passed or not.
///
/// Note that this method is only fully supported when using the drift worker
/// shipped with the drift 2.11 release. Older workers are only supported when
/// [databaseName] is non-null.
static Future<WasmProbeResult> probe({
required Uri sqlite3Uri,
required Uri driftWorkerUri,
String? databaseName,
}) async {
return await WasmDatabaseOpener(sqlite3Uri, driftWorkerUri, databaseName)
.probe();
}
/// The entrypoint for a web worker suitable for use with [open].

View File

@ -1,18 +1,7 @@
Integration tests for `package:drift/native.dart`.
To test persistence, we need to instrument a browsers in a way not covered by the normal
`test` package. For instance, we need to reload pages to ensure data is still there.
To run the tests automatically (with us managing a browser driver), just run `dart test`.
## Running tests with Firefox
```
geckodriver &
dart run tool/drift_wasm_test.dart firefox http://localhost:4444
```
## Running tests with Chrome
```
chromedriver --port=4444 --url-base=wd/hub &
dart run tool/drift_wasm_test.dart chrome http://localhost:4444/wd/hub/
```
To manually debug issues, it might make sense to trigger some functionality manually.
You can run `dart run tool/server_manually.dart` to start a web server hosting the test
content on http://localhost:8080.

View File

@ -105,6 +105,7 @@ class DriftWebDriver {
({
Set<WasmStorageImplementation> storages,
Set<MissingBrowserFeature> missingFeatures,
List<ExistingDatabase> existing,
})> probeImplementations() async {
final rawResult = await driver
.executeAsync('detectImplementations("", arguments[0])', []);
@ -119,6 +120,13 @@ class DriftWebDriver {
for (final entry in result['missing'])
MissingBrowserFeature.values.byName(entry)
},
existing: <ExistingDatabase>[
for (final entry in result['existing'])
(
WebStorageApi.byName[entry[0] as String]!,
entry[1] as String,
),
],
);
}
@ -149,4 +157,10 @@ class DriftWebDriver {
throw 'Could not set initialization mode';
}
}
Future<void> deleteDatabase(WebStorageApi storageApi, String name) async {
await driver.executeAsync('delete_database(arguments[0], arguments[1])', [
json.encode([storageApi.name, name]),
]);
}
}

View File

@ -118,6 +118,29 @@ void main() {
}
});
if (entry != WasmStorageImplementation.inMemory) {
test('delete', () async {
final impl = await driver.probeImplementations();
expect(impl.existing, isEmpty);
await driver.openDatabase(entry);
await driver.insertIntoDatabase();
await driver.waitForTableUpdate();
await driver.driver.refresh(); // Reset JS state
final newImpls = await driver.probeImplementations();
expect(newImpls.existing, hasLength(1));
final existing = newImpls.existing[0];
await driver.deleteDatabase(existing.$1, existing.$2);
await driver.driver.refresh();
final finalImpls = await driver.probeImplementations();
expect(finalImpls.existing, isEmpty);
});
}
group(
'initialization from ',
() {

View File

@ -5,14 +5,15 @@ import 'dart:js_util';
import 'package:async/async.dart';
import 'package:drift/drift.dart';
import 'package:drift/wasm.dart';
// ignore: invalid_use_of_internal_member
import 'package:drift/src/web/wasm_setup.dart';
import 'package:http/http.dart' as http;
import 'package:web_wasm/initialization_mode.dart';
import 'package:web_wasm/src/database.dart';
import 'package:sqlite3/wasm.dart';
const dbName = 'drift_test';
final sqlite3WasmUri = Uri.parse('/sqlite3.wasm');
final driftWorkerUri = Uri.parse('/worker.dart.js');
TestDatabase? openedDatabase;
StreamQueue<void>? tableUpdates;
@ -28,10 +29,27 @@ void main() {
initializationMode = InitializationMode.values.byName(arg!);
return true;
});
_addCallbackForWebDriver('delete_database', (arg) async {
final result = await WasmDatabase.probe(
sqlite3Uri: sqlite3WasmUri,
driftWorkerUri: driftWorkerUri,
);
final decoded = json.decode(arg!);
await result.deleteDatabase(
(WebStorageApi.byName[decoded[0] as String]!, decoded[1] as String),
);
});
document.getElementById('selfcheck')?.onClick.listen((event) async {
print('starting');
final database = await _opener.open();
final database = await WasmDatabase.open(
databaseName: dbName,
sqlite3Uri: sqlite3WasmUri,
driftWorkerUri: driftWorkerUri,
initializeDatabase: _initializeDatabase,
);
print('selected storage: ${database.chosenImplementation}');
print('missing features: ${database.missingFeatures}');
@ -54,77 +72,81 @@ void _addCallbackForWebDriver(String name, Future Function(String?) impl) {
}));
}
WasmDatabaseOpener get _opener {
Future<Uint8List> Function()? initializeDatabase;
Future<Uint8List?> _initializeDatabase() async {
switch (initializationMode) {
case InitializationMode.loadAsset:
initializeDatabase = () async {
final response = await http.get(Uri.parse('/initial.db'));
return response.bodyBytes;
};
final response = await http.get(Uri.parse('/initial.db'));
return response.bodyBytes;
case InitializationMode.migrateCustomWasmDatabase:
initializeDatabase = () async {
// Let's first open a custom WasmDatabase, the way it would have been
// done before WasmDatabase.open.
final sqlite3 =
await WasmSqlite3.loadFromUrl(Uri.parse('/sqlite3.wasm'));
final fs = await IndexedDbFileSystem.open(dbName: dbName);
sqlite3.registerVirtualFileSystem(fs, makeDefault: true);
final wasmDb = WasmDatabase(sqlite3: sqlite3, path: '/app.db');
final db = TestDatabase(wasmDb);
await db
.into(db.testTable)
.insert(TestTableCompanion.insert(content: 'from old database'));
await db.close();
// Let's first open a custom WasmDatabase, the way it would have been
// done before WasmDatabase.open.
final sqlite3 = await WasmSqlite3.loadFromUrl(Uri.parse('/sqlite3.wasm'));
final fs = await IndexedDbFileSystem.open(dbName: dbName);
sqlite3.registerVirtualFileSystem(fs, makeDefault: true);
final (file: file, outFlags: _) =
fs.xOpen(Sqlite3Filename('/app.db'), 0);
final blob = Uint8List(file.xFileSize());
file.xRead(blob, 0);
file.xClose();
fs.xDelete('/app.db', 0);
await fs.close();
final wasmDb = WasmDatabase(sqlite3: sqlite3, path: '/app.db');
final db = TestDatabase(wasmDb);
await db
.into(db.testTable)
.insert(TestTableCompanion.insert(content: 'from old database'));
await db.close();
return blob;
};
break;
final (file: file, outFlags: _) = fs.xOpen(Sqlite3Filename('/app.db'), 0);
final blob = Uint8List(file.xFileSize());
file.xRead(blob, 0);
file.xClose();
fs.xDelete('/app.db', 0);
await fs.close();
return blob;
case InitializationMode.none:
break;
return null;
}
return WasmDatabaseOpener(
databaseName: dbName,
sqlite3WasmUri: Uri.parse('/sqlite3.wasm'),
driftWorkerUri: Uri.parse('/worker.dart.js'),
initializeDatabase: initializeDatabase,
);
}
Future<String> _detectImplementations(String? _) async {
final opener = _opener;
await opener.probe();
final result = await WasmDatabase.probe(
sqlite3Uri: sqlite3WasmUri,
driftWorkerUri: driftWorkerUri,
databaseName: dbName,
);
return json.encode({
'impls': opener.availableImplementations.map((r) => r.name).toList(),
'missing': opener.missingFeatures.map((r) => r.name).toList(),
'impls': result.availableStorages.map((r) => r.name).toList(),
'missing': result.missingFeatures.map((r) => r.name).toList(),
'existing': result.existingDatabases.map((r) => [r.$1.name, r.$2]).toList(),
});
}
Future<void> _open(String? implementationName) async {
final opener = _opener;
WasmDatabaseResult result;
DatabaseConnection connection;
if (implementationName != null) {
await opener.probe();
result = await opener
.openWith(WasmStorageImplementation.values.byName(implementationName));
final probeResult = await WasmDatabase.probe(
sqlite3Uri: sqlite3WasmUri,
driftWorkerUri: driftWorkerUri,
databaseName: dbName,
);
connection = await probeResult.open(
WasmStorageImplementation.values.byName(implementationName),
dbName,
initializeDatabase: _initializeDatabase,
);
} else {
result = await opener.open();
final result = await WasmDatabase.open(
databaseName: dbName,
sqlite3Uri: sqlite3WasmUri,
driftWorkerUri: driftWorkerUri,
initializeDatabase: _initializeDatabase,
);
connection = result.resolvedExecutor;
}
final db = openedDatabase = TestDatabase(result.resolvedExecutor);
final db = openedDatabase = TestDatabase(connection);
// Make sure it works!
await db.customSelect('SELECT 1').get();