diff --git a/drift/CHANGELOG.md b/drift/CHANGELOG.md index ded3d5777..61ae8104d 100644 --- a/drift/CHANGELOG.md +++ b/drift/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.25.0-dev + +- Report `SqliteException`s occurring on workers as a `SqliteException` + instance. Previously, they were sent as strings only. + ## 2.24.0 - Add `TypeConverter.jsonb` to directly store values in the JSONB format used diff --git a/drift/lib/src/remote/web_protocol.dart b/drift/lib/src/remote/web_protocol.dart index 4b260e6a4..4a7addc12 100644 --- a/drift/lib/src/remote/web_protocol.dart +++ b/drift/lib/src/remote/web_protocol.dart @@ -14,7 +14,9 @@ import 'dart:js_interop'; import 'package:drift/drift.dart'; import 'package:meta/meta.dart'; +import 'package:sqlite3/common.dart' show SqliteException; +import '../web/wasm_setup/protocol.dart'; import 'protocol.dart'; @JS() @@ -44,6 +46,7 @@ final class WebProtocol { static const _tag_SuccessResponse = 1; static const _tag_ErrorResponse = 2; static const _tag_CancelledResponse = 3; + static const _tag_ErrorResponseSqliteException = 4; static const _tag_NoArgsRequest_terminateAll = 0; @@ -58,8 +61,19 @@ final class WebProtocol { static const _tag_BigInt = 14; static const _tag_Double = 15; + final ProtocolVersion _protocolVersion; + + /// Whether we can send [_tag_ErrorResponseSqliteException]. + /// + /// Since we have to apply serialization, we can't send arbitrary Dart + /// obbjects and exceptions are sent with their [Object.toString] + /// representation. Since [SqliteException]s are the most common exception + /// encountered by workers, we serialize them with their inner fields. + bool get canSerializeSqliteExceptions => + _protocolVersion >= ProtocolVersion.v4; + /// Creates the default instance for [WebProtocol]. - const WebProtocol(); + const WebProtocol([this._protocolVersion = ProtocolVersion.legacy]); /// Serializes [Message] into a JavaScript representation that is forwards- /// compatible with future drift versions. @@ -73,6 +87,30 @@ final class WebProtocol { _tag_SuccessResponse, _SerializedRequest(i: requestId, p: _serializeResponse(response)) ), + ErrorResponse( + :final requestId, + error: final SqliteException e, + :final stackTrace + ) + when canSerializeSqliteExceptions => + ( + _tag_ErrorResponseSqliteException, + [ + requestId.toJS, + stackTrace?.toString().toJS, + e.message.toJS, + e.explanation?.toJS, + e.extendedResultCode.toJS, + e.operation?.toJS, + e.causingStatement?.toJS, + switch (e.parametersToStatement) { + null => null, + final params => [ + for (final parameter in params) _encodeDbValue(parameter), + ].toJS, + }, + ].toJS + ), ErrorResponse(:final requestId, :final error, :final stackTrace) => ( _tag_ErrorResponse, [requestId.toJS, error.toString().toJS, stackTrace?.toString().toJS] @@ -105,6 +143,8 @@ final class WebProtocol { _tag_Request => decodeRequest(), _tag_SuccessResponse => decodeSuccess(), _tag_ErrorResponse => _decodeErrorResponse(payload as JSArray), + _tag_ErrorResponseSqliteException => + _decodeSqliteErrorResponse(payload as JSArray), _tag_CancelledResponse => CancelledResponse(_int(payload)), _ => throw ArgumentError('Unknown message tag $tag'), }; @@ -331,15 +371,56 @@ final class WebProtocol { } } + String? _decodeNullableString(JSAny? value) { + return value.isDefinedAndNotNull ? (value as JSString).toDart : null; + } + + StackTrace? _decodeStackStrace(JSAny? stackTrace) { + return switch (_decodeNullableString(stackTrace)) { + var s? => StackTrace.fromString(s), + _ => null, + }; + } + ErrorResponse _decodeErrorResponse(JSArray array) { final [requestId, error, stackTrace] = array.toDart; return ErrorResponse( _int(requestId), (error as JSString).toDart, - stackTrace.isDefinedAndNotNull - ? StackTrace.fromString((stackTrace as JSString).toDart) - : null, + _decodeStackStrace(stackTrace), + ); + } + + ErrorResponse _decodeSqliteErrorResponse(JSArray array) { + final [ + requestId, + stackTrace, + message, + explanation, + extendedResultCode, + operation, + causingStatement, + parametersToStatement, + ..._, + ] = array.toDart; + + return ErrorResponse( + _int(requestId), + SqliteException( + _int(extendedResultCode), + (message as JSString).toDart, + _decodeNullableString(explanation), + _decodeNullableString(causingStatement), + parametersToStatement.isDefinedAndNotNull + ? [ + for (final raw in (parametersToStatement as JSArray).toDart) + _decodeDbValue(raw), + ] + : null, + _decodeNullableString(operation), + ), + _decodeStackStrace(stackTrace), ); } } diff --git a/drift/lib/src/web/channel_new.dart b/drift/lib/src/web/channel_new.dart index 17e24f1e0..ba11c0aca 100644 --- a/drift/lib/src/web/channel_new.dart +++ b/drift/lib/src/web/channel_new.dart @@ -7,8 +7,7 @@ import 'package:drift/remote.dart'; import '../remote/protocol.dart'; import '../remote/web_protocol.dart'; - -const _protocol = WebProtocol(); +import 'wasm_setup/protocol.dart'; /// Extension to transform a raw [MessagePort] from web workers into a Dart /// [StreamChannel]. @@ -34,12 +33,15 @@ extension WebPortToChannel on web.MessagePort { /// and is not suitable for any other message. /// This allows disabling the `serialize` parameter on [connectToRemoteAndInitialize] /// and improves performance. Both endpoints need to use the same values for - /// [explicitClose] and [webNativeSerialization]. + /// [explicitClose], [webNativeSerialization] and [nativeSerializionVersion]. StreamChannel channel({ bool explicitClose = false, bool webNativeSerialization = false, + int nativeSerializionVersion = 0, }) { final controller = StreamChannelController(); + final protocol = + WebProtocol(ProtocolVersion.negotiate(nativeSerializionVersion)); onmessage = (web.MessageEvent event) { final message = event.data; @@ -48,7 +50,7 @@ extension WebPortToChannel on web.MessagePort { // Other end has closed the connection controller.local.sink.close(); } else if (webNativeSerialization) { - controller.local.sink.add(_protocol.deserialize(message as JSArray)); + controller.local.sink.add(protocol.deserialize(message as JSArray)); } else { controller.local.sink.add(message.dartify()); } @@ -56,7 +58,7 @@ extension WebPortToChannel on web.MessagePort { controller.local.stream.listen((e) { if (webNativeSerialization) { - final serialized = _protocol.serialize(e as Message); + final serialized = protocol.serialize(e as Message); postMessage(serialized); } else { postMessage(e.jsify()); diff --git a/drift/lib/src/web/wasm_setup.dart b/drift/lib/src/web/wasm_setup.dart index abf86dc95..53927cb2a 100644 --- a/drift/lib/src/web/wasm_setup.dart +++ b/drift/lib/src/web/wasm_setup.dart @@ -305,6 +305,7 @@ final class _ProbeResult implements WasmProbeResult { final local = channel.port1.channel( explicitClose: message.protocolVersion >= ProtocolVersion.v1, webNativeSerialization: message.newSerialization, + nativeSerializionVersion: message.protocolVersion.versionCode, ); var connection = await connectToRemoteAndInitialize(local, diff --git a/drift/lib/src/web/wasm_setup/protocol.dart b/drift/lib/src/web/wasm_setup/protocol.dart index a7152c732..4aaa00132 100644 --- a/drift/lib/src/web/wasm_setup/protocol.dart +++ b/drift/lib/src/web/wasm_setup/protocol.dart @@ -27,13 +27,22 @@ enum ProtocolVersion { /// This adds [ServeDriftDatabase.newSerialization]. When enabled, we /// serialize high-level protocol messages to `JSObject`s directly instead of /// using `jsify()` / `dartify()`. - v3(3); + v3(3), + + /// This makes workers serialize [SqliteException]s in a special format that + /// allows re-constructing them on the client. + /// We can't send arbitrary Dart objects through channels, so exceptions are + /// represented by [Object.toString] only. Given that most exceptions + /// encountered on web workers will end up being [SqliteException]s, treating + /// them specially allows clients to make informed decisions based on the + /// exact [SqliteException.resultCode]. + v4(4); final int versionCode; const ProtocolVersion(this.versionCode); - static const current = v3; + static const current = v4; void writeToJs(JSObject object) { object['v'] = versionCode.toJS; @@ -49,7 +58,8 @@ enum ProtocolVersion { <= 0 => legacy, 1 => v1, 2 => v2, - > 2 => current, + 3 => v3, + > 3 => current, _ => throw AssertionError(), }; } diff --git a/drift/lib/src/web/wasm_setup/shared.dart b/drift/lib/src/web/wasm_setup/shared.dart index 5749745b6..f03cb6552 100644 --- a/drift/lib/src/web/wasm_setup/shared.dart +++ b/drift/lib/src/web/wasm_setup/shared.dart @@ -267,6 +267,7 @@ class DriftServerController { message.port.channel( explicitClose: message.protocolVersion >= ProtocolVersion.v1, webNativeSerialization: message.newSerialization, + nativeSerializionVersion: message.protocolVersion.versionCode, ), // With the new serialization mode, instruct the drift server not to apply // its internal serialization logic. diff --git a/drift/test/platforms/web/remote_wasm_test.dart b/drift/test/platforms/web/remote_wasm_test.dart index 52553c19b..05ffed6d6 100644 --- a/drift/test/platforms/web/remote_wasm_test.dart +++ b/drift/test/platforms/web/remote_wasm_test.dart @@ -3,6 +3,7 @@ library; import 'package:drift/remote.dart'; import 'package:drift/src/web/channel_new.dart'; +import 'package:drift/src/web/wasm_setup/protocol.dart'; import 'package:drift/wasm.dart'; import 'package:sqlite3/wasm.dart'; import 'package:test/test.dart'; @@ -17,7 +18,33 @@ void main() { }); group('with new serialization', () { - runAllTests(_RemoteWebExecutor(true)); + final executor = _RemoteWebExecutor(true); + + runAllTests(executor); + + test('recovers sqlite exceptions', () async { + final connection = Database(executor.createConnection()); + await expectLater( + () => connection.customSelect( + 'select throw(?);', + variables: [ + Variable.withString('a'), + ], + ).get(), + throwsA( + isA().having( + (e) => e.remoteCause, + 'remoteCause', + isA().having( + (e) => e.toString(), + 'toString()', + 'SqliteException(1): while selecting from statement, "exception", SQL logic error (code 1)\n' + ' Causing statement: select throw(?);, parameters: a', + ), + ), + ), + ); + }); }); } @@ -44,6 +71,13 @@ final class _RemoteWebExecutor extends TestExecutor { WasmDatabase( sqlite3: sqlite, path: '/db', + setup: (database) => { + database.createFunction( + functionName: 'throw', + function: (_) => throw 'exception', + argumentCount: const AllowedArgumentCount(1), + ), + }, ), allowRemoteShutdown: true, ); @@ -51,12 +85,14 @@ final class _RemoteWebExecutor extends TestExecutor { final clientChannel = channel.port2.channel( explicitClose: true, webNativeSerialization: _newSerialization, + nativeSerializionVersion: ProtocolVersion.current.versionCode, ); server.serve( channel.port1.channel( explicitClose: true, webNativeSerialization: _newSerialization, + nativeSerializionVersion: ProtocolVersion.current.versionCode, ), serialize: !_newSerialization, );