Skip to content

Commit

Permalink
Serialize SqliteExceptions on web channels
Browse files Browse the repository at this point in the history
  • Loading branch information
simolus3 committed Jan 23, 2025
1 parent ffa1fac commit 6fbeca5
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 13 deletions.
5 changes: 5 additions & 0 deletions drift/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
89 changes: 85 additions & 4 deletions drift/lib/src/remote/web_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;

Expand All @@ -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.
Expand All @@ -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 => <JSAny?>[
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]
Expand Down Expand Up @@ -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'),
};
Expand Down Expand Up @@ -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),
);
}
}
Expand Down
12 changes: 7 additions & 5 deletions drift/lib/src/web/channel_new.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -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<Object?> channel({
bool explicitClose = false,
bool webNativeSerialization = false,
int nativeSerializionVersion = 0,
}) {
final controller = StreamChannelController<Object?>();
final protocol =
WebProtocol(ProtocolVersion.negotiate(nativeSerializionVersion));

onmessage = (web.MessageEvent event) {
final message = event.data;
Expand All @@ -48,15 +50,15 @@ 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());
}
}.toJS;

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());
Expand Down
1 change: 1 addition & 0 deletions drift/lib/src/web/wasm_setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions drift/lib/src/web/wasm_setup/protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,7 +58,8 @@ enum ProtocolVersion {
<= 0 => legacy,
1 => v1,
2 => v2,
> 2 => current,
3 => v3,
> 3 => current,
_ => throw AssertionError(),
};
}
Expand Down
1 change: 1 addition & 0 deletions drift/lib/src/web/wasm_setup/shared.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 37 additions & 1 deletion drift/test/platforms/web/remote_wasm_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<DriftRemoteException>().having(
(e) => e.remoteCause,
'remoteCause',
isA<SqliteException>().having(
(e) => e.toString(),
'toString()',
'SqliteException(1): while selecting from statement, "exception", SQL logic error (code 1)\n'
' Causing statement: select throw(?);, parameters: a',
),
),
),
);
});
});
}

Expand All @@ -44,19 +71,28 @@ 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,
);
final channel = MessageChannel();
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,
);
Expand Down

0 comments on commit 6fbeca5

Please sign in to comment.