Коммит 0101ce0c создал по автору Simon Binder's avatar Simon Binder
Просмотр файлов

Add integration tests for sqlite3_web

владелец 12d57a5d
......@@ -23,6 +23,7 @@ targets:
enabled: true
generate_for:
- example/worker.dart
- web/worker.dart
options:
compiler: dart2js
build_web_compilers:dart2js_archive_extractor:
......@@ -34,9 +35,11 @@ targets:
generate_for:
include:
- "example/**"
- web/**
# This one is compiled in the other target
exclude:
- "example/worker.dart"
- web/worker.dart
# We have a designated target for this step.
build_web_compilers:dart2js_archive_extractor:
enabled: false
export 'src/api.dart';
export 'src/types.dart';
export 'src/database.dart';
......@@ -6,8 +6,9 @@ import 'package:sqlite3/common.dart';
import 'package:web/web.dart'
hide Response, Request, FileSystem, Notification, Lock;
import 'api.dart';
import 'types.dart';
import 'channel.dart';
import 'database.dart';
import 'protocol.dart';
import 'shared.dart';
......@@ -279,6 +280,7 @@ final class DatabaseClient implements WebSqlite {
_missingFeatures.add(MissingBrowserFeature.fileSystemAccess);
}
available.add((StorageMode.inMemory, AccessMode.throughSharedWorker));
if (!result.sharedCanSpawnDedicated) {
_missingFeatures
.add(MissingBrowserFeature.dedicatedWorkersInSharedWorkers);
......
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:sqlite3/wasm.dart';
import 'types.dart';
import 'client.dart';
import 'worker.dart';
/// A [StorageMode], name pair representing an existing database already stored
/// by the current browsing context.
typedef ExistingDatabase = (StorageMode, String);
/// Types of files persisted for databases by virtual file system
/// implementations.
enum FileType {
/// The main database file.
database,
/// A journal file used to synchronize changes toe database file.
journal,
}
/// Available locations to store database content in browsers.
enum StorageMode {
// Note: Indices in this enum are used in the protocol, changing them is a
// backwards-incompatible change.
/// A origin-private folder provided by the file system access API.
///
/// This is generally considered to be the most reliable way to store large
/// data efficiently.
opfs,
/// A controller responsible for opening databases in the worker.
abstract base class DatabaseController {
/// Loads a wasm module from the given [uri] with the specified [headers].
Future<WasmSqlite3> loadWasmModule(Uri uri,
{Map<String, String>? headers}) async {
return WasmSqlite3.loadFromUrl(uri, headers: headers);
}
/// A virtual file system implemented by splitting files into chunks which are
/// then stored in IndexedDB.
/// Opens a database in the pre-configured [sqlite3] instance under the
/// specified [path] in the given [vfs].
///
/// As sqlite3 expects a synchronous file system and IndexedDB is
/// asynchronous, we maintain the illusion if synchronous access by keeping
/// the entire database cached in memory and then flushing changes
/// asynchronously.
/// This technically looses durability, but is reasonably reliable in
/// practice.
indexedDb,
/// Don't persist databases, instead keeping them in memory only.
inMemory,
}
/// This should virtually always call `sqlite3.open(path, vfs: vfs)` and wrap
/// the result in a [WorkerDatabase] subclass.
Future<WorkerDatabase> openDatabase(
WasmSqlite3 sqlite3, String path, String vfs);
/// In addition to the [StorageMode] describing which browser API is used to
/// store content, this enum describes how databases are accessed.
enum AccessMode {
/// Access databases by spawning a shared worker shared across tabs.
/// Handles custom requests from clients that are not bound to a database.
///
/// This is more efficient as it avoids synchronization conflicts between tabs
/// which may slow things down.
throughSharedWorker,
/// Access databases by spawning a dedicated worker for this tab.
throughDedicatedWorker,
/// Access databases without any shared or dedicated worker.
inCurrentContext,
}
/// An exception thrown when a operation fails on the remote worker.
///
/// As the worker and the main tab have been compiled independently and don't
/// share a class hierarchy or object representations, it is impossible to send
/// typed exception objects. Instead, this exception wraps every error or
/// exception thrown by the remote worker and contains the [toString]
/// representation in [message].
final class RemoteException implements Exception {
/// The [Object.toString] representation of the original exception.
final String message;
/// Creates a remote exception from the [message] thrown.
RemoteException({required this.message});
@override
String toString() {
return 'Remote error: $message';
}
}
abstract class FileSystem {
StorageMode get storage;
String get databaseName;
Future<bool> exists(FileType type);
Future<Uint8List> readFile(FileType type);
Future<void> writeFile(FileType type, Uint8List content);
/// This is not currently used.
Future<JSAny?> handleCustomRequest(
ClientConnection connection, JSAny? request);
}
/// Abstraction over a database either available locally or in a remote worker.
......@@ -158,100 +97,6 @@ abstract class WorkerDatabase {
ClientConnection connection, JSAny? request);
}
/// A controller responsible for opening databases in the worker.
abstract base class DatabaseController {
/// Loads a wasm module from the given [uri] with the specified [headers].
Future<WasmSqlite3> loadWasmModule(Uri uri,
{Map<String, String>? headers}) async {
return WasmSqlite3.loadFromUrl(uri, headers: headers);
}
/// Opens a database in the pre-configured [sqlite3] instance under the
/// specified [path] in the given [vfs].
///
/// This should virtually always call `sqlite3.open(path, vfs: vfs)` and wrap
/// the result in a [WorkerDatabase] subclass.
Future<WorkerDatabase> openDatabase(
WasmSqlite3 sqlite3, String path, String vfs);
/// Handles custom requests from clients that are not bound to a database.
///
/// This is not currently used.
Future<JSAny?> handleCustomRequest(
ClientConnection connection, JSAny? request);
}
/// 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
/// understand why a particular file system implementation is unavailable.
enum MissingBrowserFeature {
/// The browser is missing support for [shared workers].
///
/// [shared workers]: https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker
sharedWorkers,
/// The browser is missing support for [web workers] in general.
///
/// [web workers]: https://developer.mozilla.org/en-US/docs/Web/API/Worker
dedicatedWorkers,
/// The browser doesn't allow shared workers to spawn dedicated workers in
/// their context.
///
/// While the specification for web workers explicitly allows this, this
/// feature is only implemented by Firefox at the time of writing.
dedicatedWorkersInSharedWorkers,
/// The browser doesn't allow dedicated workers to spawn their own dedicated
/// workers.
dedicatedWorkersCanNest,
/// The browser does not support a synchronous version of the [File System API]
///
/// [File System API]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
fileSystemAccess,
/// The browser does not support IndexedDB.
indexedDb,
/// The browser does not support shared array buffers and `Atomics.wait`.
///
/// To enable this feature in most browsers, you need to serve your app with
/// two [special headers](https://web.dev/coop-coep/).
sharedArrayBuffers,
}
/// The result of [WebSqlite.runFeatureDetection], describing which browsers
/// and databases are available in the current browser.
final class FeatureDetectionResult {
/// A list of features that were probed and found to be unsupported in the
/// current browser.
final List<MissingBrowserFeature> missingFeatures;
/// All existing databases that have been found.
///
/// Databases are only found reliably when a database name is passed to
/// [WebSqlite.runFeatureDetection].
final List<ExistingDatabase> existingDatabases;
/// All available [StorageMode], [AccessMode] pairs describing the databases
/// supported by this browser.
final List<(StorageMode, AccessMode)> availableImplementations;
FeatureDetectionResult({
required this.missingFeatures,
required this.existingDatabases,
required this.availableImplementations,
});
@override
String toString() {
return 'Existing: $existingDatabases, available: '
'$availableImplementations, missing: $missingFeatures';
}
}
/// The result of [WebSqlite.connectToRecommended], containing the opened
/// [database] as well as the [FeatureDetectionResult] leading to that database
/// implementation being chosen.
......
......@@ -6,7 +6,7 @@ import 'package:sqlite3/common.dart';
import 'package:sqlite3/wasm.dart' as wasm_vfs;
import 'package:web/web.dart';
import 'api.dart';
import 'types.dart';
import 'channel.dart';
/// Signature of a function allowing structured data to be sent between JS
......
import 'dart:typed_data';
/// A [StorageMode], name pair representing an existing database already stored
/// by the current browsing context.
typedef ExistingDatabase = (StorageMode, String);
/// Types of files persisted for databases by virtual file system
/// implementations.
enum FileType {
/// The main database file.
database,
/// A journal file used to synchronize changes toe database file.
journal,
}
/// Available locations to store database content in browsers.
enum StorageMode {
// Note: Indices in this enum are used in the protocol, changing them is a
// backwards-incompatible change.
/// A origin-private folder provided by the file system access API.
///
/// This is generally considered to be the most reliable way to store large
/// data efficiently.
opfs,
/// A virtual file system implemented by splitting files into chunks which are
/// then stored in IndexedDB.
///
/// As sqlite3 expects a synchronous file system and IndexedDB is
/// asynchronous, we maintain the illusion if synchronous access by keeping
/// the entire database cached in memory and then flushing changes
/// asynchronously.
/// This technically looses durability, but is reasonably reliable in
/// practice.
indexedDb,
/// Don't persist databases, instead keeping them in memory only.
inMemory,
}
/// In addition to the [StorageMode] describing which browser API is used to
/// store content, this enum describes how databases are accessed.
enum AccessMode {
/// Access databases by spawning a shared worker shared across tabs.
///
/// This is more efficient as it avoids synchronization conflicts between tabs
/// which may slow things down.
throughSharedWorker,
/// Access databases by spawning a dedicated worker for this tab.
throughDedicatedWorker,
/// Access databases without any shared or dedicated worker.
inCurrentContext,
}
/// An exception thrown when a operation fails on the remote worker.
///
/// As the worker and the main tab have been compiled independently and don't
/// share a class hierarchy or object representations, it is impossible to send
/// typed exception objects. Instead, this exception wraps every error or
/// exception thrown by the remote worker and contains the [toString]
/// representation in [message].
final class RemoteException implements Exception {
/// The [Object.toString] representation of the original exception.
final String message;
/// Creates a remote exception from the [message] thrown.
RemoteException({required this.message});
@override
String toString() {
return 'Remote error: $message';
}
}
abstract class FileSystem {
StorageMode get storage;
String get databaseName;
Future<bool> exists(FileType type);
Future<Uint8List> readFile(FileType type);
Future<void> writeFile(FileType type, Uint8List content);
}
/// 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
/// understand why a particular file system implementation is unavailable.
enum MissingBrowserFeature {
/// The browser is missing support for [shared workers].
///
/// [shared workers]: https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker
sharedWorkers,
/// The browser is missing support for [web workers] in general.
///
/// [web workers]: https://developer.mozilla.org/en-US/docs/Web/API/Worker
dedicatedWorkers,
/// The browser doesn't allow shared workers to spawn dedicated workers in
/// their context.
///
/// While the specification for web workers explicitly allows this, this
/// feature is only implemented by Firefox at the time of writing.
dedicatedWorkersInSharedWorkers,
/// The browser doesn't allow dedicated workers to spawn their own dedicated
/// workers.
dedicatedWorkersCanNest,
/// The browser does not support a synchronous version of the [File System API]
///
/// [File System API]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
fileSystemAccess,
/// The browser does not support IndexedDB.
indexedDb,
/// The browser does not support shared array buffers and `Atomics.wait`.
///
/// To enable this feature in most browsers, you need to serve your app with
/// two [special headers](https://web.dev/coop-coep/).
sharedArrayBuffers,
}
/// The result of [WebSqlite.runFeatureDetection], describing which browsers
/// and databases are available in the current browser.
final class FeatureDetectionResult {
/// A list of features that were probed and found to be unsupported in the
/// current browser.
final List<MissingBrowserFeature> missingFeatures;
/// All existing databases that have been found.
///
/// Databases are only found reliably when a database name is passed to
/// [WebSqlite.runFeatureDetection].
final List<ExistingDatabase> existingDatabases;
/// All available [StorageMode], [AccessMode] pairs describing the databases
/// supported by this browser.
final List<(StorageMode, AccessMode)> availableImplementations;
FeatureDetectionResult({
required this.missingFeatures,
required this.existingDatabases,
required this.availableImplementations,
});
@override
String toString() {
return 'Existing: $existingDatabases, available: '
'$availableImplementations, missing: $missingFeatures';
}
}
......@@ -17,7 +17,7 @@ import 'package:web/web.dart'
// ignore: implementation_imports
import 'package:sqlite3/src/wasm/js_interop/new_file_system_access.dart';
import 'api.dart';
import 'database.dart';
import 'channel.dart';
import 'protocol.dart';
import 'shared.dart';
......
......@@ -14,9 +14,17 @@ dependencies:
dev_dependencies:
lints: ^2.1.0
test: ^1.24.0
test: ^1.25.5
build_web_compilers: ^4.0.9
build_runner: ^2.4.8
build_daemon: ^4.0.2
webdriver: ^3.0.3
shelf: ^1.4.1
shelf_proxy: ^1.0.4
package_config: ^2.1.0
path: ^1.9.0
collection: ^1.18.0
async: ^2.11.0
dependency_overrides:
sqlite3:
......
import 'dart:io';
import 'package:sqlite3_web/src/types.dart';
import 'package:test/test.dart';
import 'package:webdriver/async_io.dart';
import '../tool/server.dart';
enum Browser {
chrome(
driverUriString: 'http://localhost:4444/wd/hub/',
isChromium: true,
unsupportedImplementations: {
(StorageMode.opfs, AccessMode.throughSharedWorker)
},
missingFeatures: {MissingBrowserFeature.dedicatedWorkersInSharedWorkers},
),
firefox(driverUriString: 'http://localhost:4444/');
final bool isChromium;
final String driverUriString;
final Set<(StorageMode, AccessMode)> unsupportedImplementations;
final Set<MissingBrowserFeature> missingFeatures;
const Browser({
required this.driverUriString,
this.isChromium = false,
this.unsupportedImplementations = const {},
this.missingFeatures = const {},
});
Uri get driverUri => Uri.parse(driverUriString);
Set<(StorageMode, AccessMode)> get availableImplementations {
final available = <(StorageMode, AccessMode)>{};
for (final storage in StorageMode.values) {
for (final access in AccessMode.values) {
if (access != AccessMode.inCurrentContext &&
!unsupportedImplementations.contains((storage, access))) {
available.add((storage, access));
}
}
}
return available;
}
bool supports((StorageMode, AccessMode) impl) =>
!unsupportedImplementations.contains(impl);
Future<Process> spawnDriver() async {
return switch (this) {
firefox => Process.start('geckodriver', []).then((result) async {
// geckodriver seems to take a while to initialize
await Future.delayed(const Duration(seconds: 1));
return result;
}),
chrome =>
Process.start('chromedriver', ['--port=4444', '--url-base=/wd/hub']),
};
}
}
void main() {
late TestAssetServer server;
setUpAll(() async {
server = await TestAssetServer.start();
});
tearDownAll(() => server.close());
for (final browser in Browser.values) {
group(browser.name, () {
late Process driverProcess;
late DriftWebDriver driver;
setUpAll(() async => driverProcess = await browser.spawnDriver());
tearDownAll(() => driverProcess.kill());
setUp(() async {
final rawDriver = await createDriver(
spec: browser.isChromium ? WebDriverSpec.JsonWire : WebDriverSpec.W3c,
uri: browser.driverUri,
);
driver = DriftWebDriver(server, rawDriver);
await driver.driver.get('http://localhost:8080/');
});
tearDown(() => driver.driver.quit());
test('compatibility check', () async {
final result = await driver.probeImplementations();
expect(result.missingFeatures, browser.missingFeatures);
expect(result.impls, browser.availableImplementations);
});
});
}
}
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:build_daemon/client.dart';
import 'package:build_daemon/constants.dart';
import 'package:build_daemon/data/build_status.dart';
import 'package:build_daemon/data/build_target.dart';
import 'package:collection/collection.dart';
import 'package:package_config/package_config.dart';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf_io.dart';
import 'package:shelf_proxy/shelf_proxy.dart';
import 'package:webdriver/async_io.dart';
import 'package:sqlite3_web/src/types.dart';
void main() async {
await TestAssetServer.start();
print('Serving on http://localhost:8080/');
}
class TestAssetServer {
final BuildDaemonClient buildRunner;
late final HttpServer server;
TestAssetServer(this.buildRunner);
Future<void> close() async {
await server.close(force: true);
await buildRunner.close();
}
static Future<TestAssetServer> start() async {
final packageConfig =
await loadPackageConfigUri((await Isolate.packageConfig)!);
final ownPackage = packageConfig['sqlite3_web']!.root;
var packageDir = ownPackage.toFilePath(windows: Platform.isWindows);
if (packageDir.endsWith('/')) {
packageDir = packageDir.substring(0, packageDir.length - 1);
}
final buildRunner = await BuildDaemonClient.connect(
packageDir,
[
Platform.executable, // dart
'run',
'build_runner',
'daemon',
],
logHandler: (log) => print(log.message),
);
buildRunner
..registerBuildTarget(DefaultBuildTarget((b) => b.target = 'web'))
..startBuild();
// Wait for the build to complete, so that the server we return is ready to
// go.
await buildRunner.buildResults.firstWhere((b) {
final buildResult = b.results.firstWhereOrNull((r) => r.target == 'web');
return buildResult != null && buildResult.status != BuildStatus.started;
});
final assetServerPortFile =
File(p.join(daemonWorkspace(packageDir), '.asset_server_port'));
final assetServerPort = int.parse(await assetServerPortFile.readAsString());
final server = TestAssetServer(buildRunner);
final proxy = proxyHandler('http://localhost:$assetServerPort/web/');
server.server = await serve(
(request) async {
final pathSegments = request.url.pathSegments;
if (pathSegments.isNotEmpty && pathSegments[0] == 'no-coep') {
// Serve stuff under /no-coep like the regular website, but without
// adding the security headers.
return await proxy(request.change(path: 'no-coep'));
} else {
final response = await proxy(request);
if (!request.url.path.startsWith('/no-coep')) {
return response.change(headers: {
// Needed for shared array buffers to work
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
});
}
return response;
}
},
'localhost',
8080,
);
return server;
}
}
class DriftWebDriver {
final TestAssetServer server;
final WebDriver driver;
DriftWebDriver(this.server, this.driver);
Future<
({
Set<(StorageMode, AccessMode)> impls,
Set<MissingBrowserFeature> missingFeatures,
List<ExistingDatabase> existing,
})> probeImplementations() async {
final rawResult = await driver
.executeAsync('detectImplementations("", arguments[0])', []);
final result = json.decode(rawResult);
return (
impls: {
for (final entry in result['impls'])
(
StorageMode.values.byName(entry[0] as String),
AccessMode.values.byName(entry[1] as String),
)
},
missingFeatures: {
for (final entry in result['missing'])
MissingBrowserFeature.values.byName(entry)
},
existing: <ExistingDatabase>[
for (final entry in result['existing'])
(
StorageMode.values.byName(entry[0] as String),
entry[1] as String,
),
],
);
}
Future<void> openDatabase([(StorageMode, AccessMode)? implementation]) async {
final desc = switch (implementation) {
null => null,
(var storage, var access) => '${storage.name}:${access.name}'
};
await driver.executeAsync('open(arguments[0], arguments[1])', [desc]);
}
Future<void> closeDatabase() async {
await driver.executeAsync("close('', arguments[0])", []);
}
Future<void> waitForUpdate() async {
await driver.executeAsync('wait_for_update("", arguments[0])', []);
}
Future<void> execute(String sql) async {
await driver.executeAsync('exec(arguments[0], arguments[1])', [sql]);
}
}
import 'dart:js_interop';
import 'package:sqlite3/common.dart';
import 'package:sqlite3/src/wasm/sqlite3.dart';
import 'package:sqlite3_web/sqlite3_web.dart';
final class ExampleController extends DatabaseController {
final bool isInWorker;
ExampleController({required this.isInWorker});
@override
Future<JSAny?> handleCustomRequest(
ClientConnection connection, JSAny? request) async {
return null;
}
@override
Future<WorkerDatabase> openDatabase(
WasmSqlite3 sqlite3, String path, String vfs) async {
final raw = sqlite3.open(path, vfs: vfs);
raw.createFunction(
functionName: 'database_host',
function: (args) => isInWorker ? 'worker' : 'document',
argumentCount: const AllowedArgumentCount(0),
);
return ExampleDatabase(database: raw);
}
}
final class ExampleDatabase extends WorkerDatabase {
@override
final CommonDatabase database;
ExampleDatabase({required this.database});
@override
Future<JSAny?> handleCustomRequest(
ClientConnection connection, JSAny? request) async {
return null;
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="scaffolded-by" content="https://github.com/dart-lang/sdk">
<title>web_wasm</title>
<script defer src="main.dart.js"></script>
</head>
<body>
<h1><code>package:sqlite3_web</code> integration tests</h1>
<p>
Welcome! For integration tests, this page is opened through a web driver, and scripts are injected
to run tests. To test that things are generally working, you can start a quick self-check here.
Results are logged to the console.
<button id="selfcheck">Check that this browser works</button>
<a href="/" target="_blank" id="newtab">Open this page in a new tab</a>
</p>
</body>
</html>
import 'dart:convert';
import 'dart:html';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:async/async.dart';
import 'package:sqlite3_web/sqlite3_web.dart';
final sqlite3WasmUri = Uri.parse('sqlite3.wasm');
final workerUri = Uri.parse('worker.dart.js');
const databasName = 'database';
WebSqlite? webSqlite;
Database? database;
StreamQueue<void>? updates;
void main() {
_addCallbackForWebDriver('detectImplementations', _detectImplementations);
_addCallbackForWebDriver('close', (arg) async {
await database?.dispose();
return null;
});
_addCallbackForWebDriver('wait_for_update', _waitForUpdate);
_addCallbackForWebDriver('open', _open);
_addCallbackForWebDriver('exec', _exec);
document.getElementById('selfcheck')?.onClick.listen((event) async {
print('starting');
final sqlite = initializeSqlite();
final database = await sqlite.connectToRecommended(databasName);
print('selected storage: ${database.storage} through ${database.access}');
print('missing features: ${database.features.missingFeatures}');
});
}
void _addCallbackForWebDriver(
String name, Future<JSAny?> Function(String?) impl) {
globalContext.setProperty(
name.toJS,
(JSString? arg, JSFunction callback) {
Future(() async {
JSAny? result;
try {
result = await impl(arg?.toDart);
} catch (e, s) {
final console = globalContext['console']! as JSObject;
console.callMethod(
'error'.toJS, e.toString().toJS, s.toString().toJS);
}
callback.callAsFunction(null, result);
});
}.toJS,
);
}
WebSqlite initializeSqlite() {
return webSqlite ??= WebSqlite.open(
worker: workerUri,
wasmModule: sqlite3WasmUri,
);
}
Future<JSString> _detectImplementations(String? _) async {
final instance = initializeSqlite();
final result = await instance.runFeatureDetection(databaseName: 'database');
return json.encode({
'impls': result.availableImplementations
.map((r) => [r.$1.name, r.$2.name])
.toList(),
'missing': result.missingFeatures.map((r) => r.name).toList(),
'existing': result.existingDatabases.map((r) => [r.$1.name, r.$2]).toList(),
}).toJS;
}
Future<JSAny?> _waitForUpdate(String? _) async {
await updates!.next;
return null;
}
Future<JSAny?> _open(String? implementationName) async {
final sqlite = initializeSqlite();
Database db;
if (implementationName != null) {
final split = implementationName.split(':');
db = await sqlite.connect(databasName, StorageMode.values.byName(split[0]),
AccessMode.values.byName(split[1]));
} else {
final result = await sqlite.connectToRecommended(databasName);
db = result.database;
}
// Make sure it works!
await db.select('SELECT database_host()');
updates = StreamQueue(db.updates);
database = db;
return null;
}
Future<JSAny?> _exec(String? sql) async {
database!.execute(sql!);
return null;
}
import 'package:sqlite3_web/sqlite3_web.dart';
import 'controller.dart';
void main() {
WebSqlite.workerEntrypoint(controller: ExampleController(isInWorker: true));
}
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать