From 47c53d0992e625a23cb674b7cbacad3fc14ad9ff Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 1/6] feat(codec): add Hrana 3 WebSocket Protobuf codec --- .../hrana/codec/HranaWsProtobufCodec.java | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java new file mode 100644 index 0000000..d02d6a3 --- /dev/null +++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java @@ -0,0 +1,237 @@ +package com.github.magnusp.libsql.hrana.codec; + +import com.github.magnusp.libsql.hrana.proto.HranaProto; +import com.github.magnusp.libsql.hrana.proto.HranaWsProto; +import com.google.protobuf.ByteString; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Hrana 3 WebSocket message codec for the Protobuf encoding ({@code hrana3-protobuf} + * subprotocol). + * + *

Every client or server message is a complete {@code hrana.ws.ClientMsg} or {@code + * hrana.ws.ServerMsg} serialized as one binary WebSocket frame. + */ +public final class HranaWsProtobufCodec implements HranaWsCodec { + + @Override + public boolean isBinary() { + return true; + } + + @Override + public byte[] serialize(WsClientMsg msg) throws IOException { + HranaWsProto.ClientMsg.Builder clientMsg = HranaWsProto.ClientMsg.newBuilder(); + switch (msg) { + case WsClientMsg.Hello hello -> { + HranaWsProto.HelloMsg.Builder helloMsg = HranaWsProto.HelloMsg.newBuilder(); + if (hello.jwt() != null) { + helloMsg.setJwt(hello.jwt()); + } + clientMsg.setHello(helloMsg); + } + case WsClientMsg.Request request -> { + HranaWsProto.RequestMsg.Builder requestMsg = HranaWsProto.RequestMsg.newBuilder() + .setRequestId(request.requestId()); + switch (request.request()) { + case WsRequest.OpenStream open -> requestMsg.setOpenStream( + HranaWsProto.OpenStreamReq.newBuilder() + .setStreamId(open.streamId())); + case WsRequest.CloseStream close -> requestMsg.setCloseStream( + HranaWsProto.CloseStreamReq.newBuilder() + .setStreamId(close.streamId())); + case WsRequest.Execute execute -> requestMsg.setExecute( + HranaWsProto.ExecuteReq.newBuilder() + .setStreamId(execute.streamId()) + .setStmt(toProto(execute.stmt()))); + case WsRequest.Batch batch -> requestMsg.setBatch( + HranaWsProto.BatchReq.newBuilder() + .setStreamId(batch.streamId()) + .setBatch(toProto(batch.batch()))); + } + clientMsg.setRequest(requestMsg); + } + } + return clientMsg.build().toByteArray(); + } + + @Override + public WsServerMsg deserialize(byte[] payload) throws IOException { + HranaWsProto.ServerMsg serverMsg = HranaWsProto.ServerMsg.parseFrom(payload); + return switch (serverMsg.getMsgCase()) { + case HELLO_OK -> WsServerMsg.HelloOk.INSTANCE; + case HELLO_ERROR -> new WsServerMsg.HelloError( + fromProto(serverMsg.getHelloError().getError())); + case RESPONSE_OK -> new WsServerMsg.ResponseOk( + serverMsg.getResponseOk().getRequestId(), + fromProto(serverMsg.getResponseOk())); + case RESPONSE_ERROR -> new WsServerMsg.ResponseError( + serverMsg.getResponseError().getRequestId(), + fromProto(serverMsg.getResponseError().getError())); + case MSG_NOT_SET -> throw new IOException( + "Hrana protobuf server message has no msg case"); + }; + } + + // ------------------------------------------------------------- + // Client structures + // ------------------------------------------------------------- + + private static HranaProto.Stmt toProto(Stmt stmt) { + HranaProto.Stmt.Builder out = HranaProto.Stmt.newBuilder(); + if (stmt.sql() != null) { + out.setSql(stmt.sql()); + } + if (stmt.sqlId() != null) { + out.setSqlId(stmt.sqlId()); + } + if (stmt.args() != null) { + for (Value arg : stmt.args()) { + out.addArgs(toProto(arg)); + } + } + if (stmt.namedArgs() != null) { + for (NamedArg namedArg : stmt.namedArgs()) { + out.addNamedArgs(HranaProto.NamedArg.newBuilder() + .setName(namedArg.name()) + .setValue(toProto(namedArg.value()))); + } + } + if (stmt.wantRows() != null) { + out.setWantRows(stmt.wantRows()); + } + return out.build(); + } + + private static HranaProto.Value toProto(Value value) { + HranaProto.Value.Builder out = HranaProto.Value.newBuilder(); + switch (value) { + case Value.Null ignored -> out.setNull(HranaProto.Value.Null.getDefaultInstance()); + case Value.Integer integer -> out.setInteger(integer.value()); + case Value.Float floating -> out.setFloat(floating.value()); + case Value.Text text -> out.setText(text.value()); + case Value.Blob blob -> out.setBlob(ByteString.copyFrom(blob.bytes())); + } + return out.build(); + } + + private static HranaProto.Batch toProto(Batch batch) { + HranaProto.Batch.Builder out = HranaProto.Batch.newBuilder(); + for (BatchStep step : batch.steps()) { + HranaProto.BatchStep.Builder stepMsg = HranaProto.BatchStep.newBuilder() + .setStmt(toProto(step.stmt())); + if (step.condition() != null) { + stepMsg.setCondition(toProto(step.condition())); + } + out.addSteps(stepMsg); + } + return out.build(); + } + + private static HranaProto.BatchCond toProto(BatchCond condition) { + HranaProto.BatchCond.Builder out = HranaProto.BatchCond.newBuilder(); + switch (condition) { + case BatchCond.StepOk stepOk -> out.setStepOk(stepOk.step()); + case BatchCond.StepError stepError -> out.setStepError(stepError.step()); + case BatchCond.Not not -> out.setNot(toProto(not.cond())); + case BatchCond.And and -> out.setAnd(toProtoCondList(and.conds())); + case BatchCond.Or or -> out.setOr(toProtoCondList(or.conds())); + case BatchCond.IsAutocommit ignored -> out.setIsAutocommit( + HranaProto.BatchCond.IsAutocommit.getDefaultInstance()); + } + return out.build(); + } + + private static HranaProto.BatchCond.CondList toProtoCondList(List conditions) { + HranaProto.BatchCond.CondList.Builder out = HranaProto.BatchCond.CondList.newBuilder(); + for (BatchCond condition : conditions) { + out.addConds(toProto(condition)); + } + return out.build(); + } + + // ------------------------------------------------------------- + // Server structures + // ------------------------------------------------------------- + + private static WsResponse fromProto(HranaWsProto.ResponseOkMsg responseMsg) throws IOException { + return switch (responseMsg.getResponseCase()) { + case OPEN_STREAM -> WsResponse.OpenStream.INSTANCE; + case CLOSE_STREAM -> WsResponse.CloseStream.INSTANCE; + case EXECUTE -> new WsResponse.Execute( + fromProto(responseMsg.getExecute().getResult())); + case BATCH -> new WsResponse.Batch( + fromProto(responseMsg.getBatch().getResult())); + case RESPONSE_NOT_SET -> throw new IOException( + "Hrana protobuf response has no response case"); + default -> throw new IOException("Unsupported Hrana protobuf response: " + + responseMsg.getResponseCase()); + }; + } + + private static StmtResult fromProto(HranaProto.StmtResult result) throws IOException { + List cols = new ArrayList<>(result.getColsCount()); + for (HranaProto.Col col : result.getColsList()) { + cols.add(fromProto(col)); + } + List rows = new ArrayList<>(result.getRowsCount()); + for (HranaProto.Row row : result.getRowsList()) { + rows.add(fromProto(row)); + } + Long lastInsertRowid = result.hasLastInsertRowid() + ? result.getLastInsertRowid() + : null; + return new StmtResult(cols, rows, result.getAffectedRowCount(), lastInsertRowid); + } + + private static Col fromProto(HranaProto.Col col) { + return new Col( + col.hasName() ? col.getName() : null, + col.hasDecltype() ? col.getDecltype() : null); + } + + private static Row fromProto(HranaProto.Row row) throws IOException { + List values = new ArrayList<>(row.getValuesCount()); + for (HranaProto.Value value : row.getValuesList()) { + values.add(fromProto(value)); + } + return new Row(values); + } + + private static BatchResult fromProto(HranaProto.BatchResult result) throws IOException { + Map stepResults = new LinkedHashMap<>(); + for (Map.Entry entry + : result.getStepResultsMap().entrySet()) { + stepResults.put(entry.getKey(), fromProto(entry.getValue())); + } + Map stepErrors = new LinkedHashMap<>(); + for (Map.Entry entry + : result.getStepErrorsMap().entrySet()) { + stepErrors.put(entry.getKey(), fromProto(entry.getValue())); + } + return new BatchResult(stepResults, stepErrors); + } + + private static HranaError fromProto(HranaProto.Error error) { + return new HranaError( + error.getMessage(), + error.hasCode() ? error.getCode() : null); + } + + private static Value fromProto(HranaProto.Value value) throws IOException { + return switch (value.getValueCase()) { + case NULL -> Value.Null.INSTANCE; + case INTEGER -> new Value.Integer(value.getInteger()); + case FLOAT -> new Value.Float(value.getFloat()); + case TEXT -> new Value.Text(value.getText()); + case BLOB -> new Value.Blob(value.getBlob().toByteArray()); + case VALUE_NOT_SET -> throw new IOException( + "Hrana protobuf Value has no value case"); + }; + } +} From da6933315854afb89bcdfec5ec5aa8df7d6f21dc Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 2/6] feat(client): add encoding config and derive WebSocket subprotocol from codec --- .../libsql/client/LibsqlClientConfig.java | 19 ++++++++++++-- .../libsql/client/LibsqlWebSocketClient.java | 26 ++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java index b9cfe5d..bc911d4 100644 --- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java +++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java @@ -6,12 +6,27 @@ public record LibsqlClientConfig( String url, String authToken, Duration connectTimeout, - Duration busyTimeout + Duration busyTimeout, + Encoding encoding ) { public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); public static final Duration DEFAULT_BUSY_TIMEOUT = Duration.ofSeconds(5); + /** The wire encoding used for Hrana 3 over WebSocket. HTTP always speaks JSON. */ + public enum Encoding { + JSON, + PROTOBUF + } + + public LibsqlClientConfig { + encoding = encoding == null ? Encoding.JSON : encoding; + } + + public LibsqlClientConfig(String url, String authToken, Duration connectTimeout, Duration busyTimeout) { + this(url, authToken, connectTimeout, busyTimeout, Encoding.JSON); + } + public LibsqlClientConfig(String url, String authToken) { - this(url, authToken, DEFAULT_CONNECT_TIMEOUT, DEFAULT_BUSY_TIMEOUT); + this(url, authToken, DEFAULT_CONNECT_TIMEOUT, DEFAULT_BUSY_TIMEOUT, Encoding.JSON); } } diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java index c2afe0b..94a1acb 100644 --- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java +++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java @@ -40,7 +40,10 @@ import java.util.concurrent.atomic.AtomicInteger; /** - * Hrana 3 client over a WebSocket, speaking the JSON encoding ({@code hrana3} subprotocol). + * Hrana 3 client over a WebSocket. + * + *

The supplied {@link HranaWsCodec} selects both the wire encoding and the subprotocol: {@code + * hrana3} for JSON text frames and {@code hrana3-protobuf} for Protobuf binary frames. * *

One WebSocket connection multiplexes any number of streams. Requests are matched to responses * by {@code request_id}, so the client may send a whole one-shot sequence (open, execute, close) @@ -58,7 +61,8 @@ */ public class LibsqlWebSocketClient implements LibsqlClient { - private static final String SUBPROTOCOL = "hrana3"; + private static final String SUBPROTOCOL_JSON = "hrana3"; + private static final String SUBPROTOCOL_PROTOBUF = "hrana3-protobuf"; private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private static final int OP_CONTINUATION = 0x0; @@ -73,6 +77,7 @@ public class LibsqlWebSocketClient implements LibsqlClient { private final LibsqlClientConfig config; private final HranaWsCodec codec; + private final String subprotocol; private final Duration timeout; private final Transport transport; @@ -97,7 +102,8 @@ public LibsqlWebSocketClient(LibsqlClientConfig config, HranaWsCodec codec) { this.timeout = config.connectTimeout() != null ? config.connectTimeout() : LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT; - this.transport = new Transport(URI.create(config.url()), timeout); + this.subprotocol = codec.isBinary() ? SUBPROTOCOL_PROTOBUF : SUBPROTOCOL_JSON; + this.transport = new Transport(URI.create(config.url()), timeout, subprotocol); try { send(new WsClientMsg.Hello(authToken())); @@ -359,14 +365,14 @@ private final class Transport { private volatile boolean open = true; - private Transport(URI uri, Duration timeout) { + private Transport(URI uri, Duration timeout, String subprotocol) { Socket connected = null; try { connected = connect(uri, timeout); this.socket = connected; this.in = connected.getInputStream(); this.out = connected.getOutputStream(); - handshake(uri, in, out, timeout); + handshake(uri, in, out, timeout, subprotocol); } catch (IOException e) { closeQuietly(connected); throw new LibsqlException( @@ -583,8 +589,8 @@ private static Socket connect(URI uri, Duration timeout) throws IOException { } } - private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout) - throws IOException { + private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout, + String subprotocol) throws IOException { String key = createKey(); String expectedAccept = acceptKey(key); String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath(); @@ -602,7 +608,7 @@ private static void handshake(URI uri, InputStream in, OutputStream out, Duratio .append("Connection: Upgrade\r\n") .append("Sec-WebSocket-Key: ").append(key).append("\r\n") .append("Sec-WebSocket-Version: 13\r\n") - .append("Sec-WebSocket-Protocol: ").append(SUBPROTOCOL).append("\r\n") + .append("Sec-WebSocket-Protocol: ").append(subprotocol).append("\r\n") .append("\r\n"); out.write(request.toString().getBytes(StandardCharsets.US_ASCII)); out.flush(); @@ -627,8 +633,8 @@ private static void handshake(URI uri, InputStream in, OutputStream out, Duratio throw new IOException("WebSocket upgrade response has an invalid Sec-WebSocket-Accept"); } String negotiated = headers.get("sec-websocket-protocol"); - if (!SUBPROTOCOL.equals(negotiated)) { - throw new IOException("Server did not negotiate subprotocol " + SUBPROTOCOL + if (!subprotocol.equals(negotiated)) { + throw new IOException("Server did not negotiate subprotocol " + subprotocol + " (got " + negotiated + ")"); } } From 3a489c2812bbaf1e94ed191f15754ee98425358c Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 3/6] feat(jdbc): route encoding=protobuf URLs to the WebSocket Protobuf codec --- .../magnusp/libsql/jdbc/LibsqlDriver.java | 15 +++++++++++--- .../magnusp/libsql/jdbc/LibsqlUrlParser.java | 20 ++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java index c13dc11..b49431b 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java @@ -4,6 +4,9 @@ import com.github.magnusp.libsql.client.LibsqlClientConfig; import com.github.magnusp.libsql.client.LibsqlHttpClient; import com.github.magnusp.libsql.client.LibsqlWebSocketClient; +import com.github.magnusp.libsql.hrana.codec.DirectHranaJsonCodec; +import com.github.magnusp.libsql.hrana.codec.HranaWsCodec; +import com.github.magnusp.libsql.hrana.codec.HranaWsProtobufCodec; import java.sql.Connection; import java.sql.Driver; @@ -33,9 +36,15 @@ public Connection connect(String url, Properties info) throws SQLException { return null; } LibsqlClientConfig config = LibsqlUrlParser.parse(url, info); - LibsqlClient client = isWebSocketUrl(config.url()) - ? new LibsqlWebSocketClient(config) - : new LibsqlHttpClient(config); + LibsqlClient client; + if (isWebSocketUrl(config.url())) { + HranaWsCodec codec = config.encoding() == LibsqlClientConfig.Encoding.PROTOBUF + ? new HranaWsProtobufCodec() + : new DirectHranaJsonCodec(); + client = new LibsqlWebSocketClient(config, codec); + } else { + client = new LibsqlHttpClient(config); + } return new LibsqlConnection(config, client); } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index 2dbe019..ed5ee09 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -6,6 +6,7 @@ import java.sql.SQLException; import java.time.Duration; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Properties; @@ -76,6 +77,22 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx }; } + // Encoding selection. JSON stays the default; Protobuf exists only for WebSocket. + LibsqlClientConfig.Encoding encoding = LibsqlClientConfig.Encoding.JSON; + String encodingParam = queryParams.get("encoding"); + if (encodingParam != null) { + switch (encodingParam.toLowerCase(Locale.ROOT)) { + case "json" -> encoding = LibsqlClientConfig.Encoding.JSON; + case "protobuf", "proto" -> encoding = LibsqlClientConfig.Encoding.PROTOBUF; + default -> throw new SQLException( + "Unsupported encoding '" + encodingParam + "': expected json or protobuf"); + } + } + if (encoding == LibsqlClientConfig.Encoding.PROTOBUF + && !"ws".equals(scheme) && !"wss".equals(scheme)) { + throw new SQLException("encoding=protobuf is only supported for ws/wss URLs"); + } + // Timeouts Duration connectTimeout = LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT; if (queryParams.containsKey("connectTimeout") || queryParams.containsKey("socketTimeout")) { @@ -102,7 +119,8 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx } endpointUrl.append(path); - return new LibsqlClientConfig(endpointUrl.toString(), authToken, connectTimeout, busyTimeout); + return new LibsqlClientConfig( + endpointUrl.toString(), authToken, connectTimeout, busyTimeout, encoding); } private static Map parseQuery(String query) { From 4f52d9e7cf6182c02062b39f8a4619bcfc0ca39b Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 4/6] test(codec): cover Hrana WebSocket Protobuf encode and decode --- .../hrana/codec/HranaWsProtobufCodecTest.java | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java diff --git a/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java new file mode 100644 index 0000000..6fd96b6 --- /dev/null +++ b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java @@ -0,0 +1,367 @@ +package com.github.magnusp.libsql.hrana.codec; + +import com.github.magnusp.libsql.hrana.proto.HranaProto; +import com.github.magnusp.libsql.hrana.proto.HranaWsProto; +import com.google.protobuf.ByteString; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HranaWsProtobufCodecTest { + + private final HranaWsProtobufCodec codec = new HranaWsProtobufCodec(); + + private HranaWsProto.ClientMsg serialize(WsClientMsg msg) throws IOException { + return HranaWsProto.ClientMsg.parseFrom(codec.serialize(msg)); + } + + private WsServerMsg deserialize(HranaWsProto.ServerMsg msg) throws IOException { + return codec.deserialize(msg.toByteArray()); + } + + private static ByteString bytes(int... values) { + byte[] bytes = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + bytes[i] = (byte) values[i]; + } + return ByteString.copyFrom(bytes); + } + + @Test + void codecUsesBinaryFrames() { + assertThat(codec).isInstanceOf(HranaWsCodec.class); + assertThat(codec.isBinary()).isTrue(); + } + + @Test + void serializesHelloWithJwt() throws IOException { + HranaWsProto.ClientMsg msg = serialize(new WsClientMsg.Hello("jwt-token")); + + assertThat(msg.getMsgCase()).isEqualTo(HranaWsProto.ClientMsg.MsgCase.HELLO); + assertThat(msg.getHello().hasJwt()).isTrue(); + assertThat(msg.getHello().getJwt()).isEqualTo("jwt-token"); + } + + @Test + void serializesHelloWithoutJwtWhenNull() throws IOException { + HranaWsProto.ClientMsg msg = serialize(new WsClientMsg.Hello(null)); + + assertThat(msg.getMsgCase()).isEqualTo(HranaWsProto.ClientMsg.MsgCase.HELLO); + assertThat(msg.getHello().hasJwt()).isFalse(); + } + + @Test + void serializesOpenAndCloseStreamRequests() throws IOException { + HranaWsProto.RequestMsg open = serialize( + new WsClientMsg.Request(7, new WsRequest.OpenStream(3))).getRequest(); + assertThat(open.getRequestId()).isEqualTo(7); + assertThat(open.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.OPEN_STREAM); + assertThat(open.getOpenStream().getStreamId()).isEqualTo(3); + + HranaWsProto.RequestMsg close = serialize( + new WsClientMsg.Request(8, new WsRequest.CloseStream(4))).getRequest(); + assertThat(close.getRequestId()).isEqualTo(8); + assertThat(close.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.CLOSE_STREAM); + assertThat(close.getCloseStream().getStreamId()).isEqualTo(4); + } + + @Test + void serializesExecuteWithEveryValueTypeAndBothArgStyles() throws IOException { + Stmt stmt = new Stmt( + "SELECT :a", + 12, + List.of( + new Value.Integer(Long.MIN_VALUE), + new Value.Integer(Long.MAX_VALUE), + new Value.Float(1.5), + new Value.Text("h\u00e9llo \u4e16\u754c \ud83d\ude00"), + Value.Null.INSTANCE, + new Value.Blob(new byte[0]), + new Value.Blob(new byte[] {1, 2, 3})), + List.of( + new NamedArg(":a", new Value.Text("named")), + new NamedArg(":b", new Value.Integer(7))), + false); + + HranaWsProto.RequestMsg request = serialize( + new WsClientMsg.Request(9, new WsRequest.Execute(5, stmt))).getRequest(); + + assertThat(request.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.EXECUTE); + assertThat(request.getExecute().getStreamId()).isEqualTo(5); + + HranaProto.Stmt protoStmt = request.getExecute().getStmt(); + assertThat(protoStmt.hasSql()).isTrue(); + assertThat(protoStmt.getSql()).isEqualTo("SELECT :a"); + assertThat(protoStmt.hasSqlId()).isTrue(); + assertThat(protoStmt.getSqlId()).isEqualTo(12); + assertThat(protoStmt.hasWantRows()).isTrue(); + assertThat(protoStmt.getWantRows()).isFalse(); + assertThat(protoStmt.getArgsCount()).isEqualTo(7); + + assertThat(protoStmt.getArgs(0).getValueCase()) + .isEqualTo(HranaProto.Value.ValueCase.INTEGER); + assertThat(protoStmt.getArgs(0).getInteger()).isEqualTo(Long.MIN_VALUE); + assertThat(protoStmt.getArgs(1).getInteger()).isEqualTo(Long.MAX_VALUE); + assertThat(protoStmt.getArgs(2).getValueCase()) + .isEqualTo(HranaProto.Value.ValueCase.FLOAT); + assertThat(protoStmt.getArgs(2).getFloat()).isEqualTo(1.5); + assertThat(protoStmt.getArgs(3).getText()).isEqualTo("h\u00e9llo \u4e16\u754c \ud83d\ude00"); + assertThat(protoStmt.getArgs(4).getValueCase()) + .isEqualTo(HranaProto.Value.ValueCase.NULL); + assertThat(protoStmt.getArgs(5).getValueCase()) + .isEqualTo(HranaProto.Value.ValueCase.BLOB); + assertThat(protoStmt.getArgs(5).getBlob()).isEqualTo(ByteString.EMPTY); + assertThat(protoStmt.getArgs(6).getBlob()).isEqualTo(bytes(1, 2, 3)); + + assertThat(protoStmt.getNamedArgsCount()).isEqualTo(2); + assertThat(protoStmt.getNamedArgs(0).getName()).isEqualTo(":a"); + assertThat(protoStmt.getNamedArgs(0).getValue().getText()).isEqualTo("named"); + assertThat(protoStmt.getNamedArgs(1).getName()).isEqualTo(":b"); + assertThat(protoStmt.getNamedArgs(1).getValue().getInteger()).isEqualTo(7); + } + + @Test + void serializesBatchWithEveryConditionAndAnUnconditionalStep() throws IOException { + Batch batch = new Batch(List.of( + new BatchStep(new BatchCond.StepOk(0), new Stmt("BEGIN")), + new BatchStep(new BatchCond.StepError(1), new Stmt("ROLLBACK")), + new BatchStep(new BatchCond.Not(new BatchCond.StepOk(2)), new Stmt("COMMIT")), + new BatchStep(new BatchCond.And(List.of( + new BatchCond.StepOk(0), new BatchCond.StepOk(1))), new Stmt("INSERT 1")), + new BatchStep(new BatchCond.Or(List.of( + new BatchCond.StepError(0), BatchCond.IsAutocommit.INSTANCE)), new Stmt("INSERT 2")), + new BatchStep(BatchCond.IsAutocommit.INSTANCE, new Stmt("INSERT 3")), + new BatchStep(new Stmt("SELECT 1")))); + + HranaWsProto.RequestMsg request = serialize( + new WsClientMsg.Request(10, new WsRequest.Batch(6, batch))).getRequest(); + + assertThat(request.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.BATCH); + assertThat(request.getBatch().getStreamId()).isEqualTo(6); + + HranaProto.Batch protoBatch = request.getBatch().getBatch(); + assertThat(protoBatch.getStepsCount()).isEqualTo(7); + assertThat(protoBatch.getSteps(0).getStmt().getSql()).isEqualTo("BEGIN"); + assertThat(protoBatch.getSteps(6).hasCondition()).isFalse(); + + HranaProto.BatchCond stepOk = protoBatch.getSteps(0).getCondition(); + assertThat(stepOk.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_OK); + assertThat(stepOk.getStepOk()).isEqualTo(0); + + HranaProto.BatchCond stepError = protoBatch.getSteps(1).getCondition(); + assertThat(stepError.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_ERROR); + assertThat(stepError.getStepError()).isEqualTo(1); + + HranaProto.BatchCond not = protoBatch.getSteps(2).getCondition(); + assertThat(not.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.NOT); + assertThat(not.getNot().getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_OK); + + HranaProto.BatchCond and = protoBatch.getSteps(3).getCondition(); + assertThat(and.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.AND); + assertThat(and.getAnd().getCondsCount()).isEqualTo(2); + assertThat(and.getAnd().getConds(1).getStepOk()).isEqualTo(1); + + HranaProto.BatchCond or = protoBatch.getSteps(4).getCondition(); + assertThat(or.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.OR); + assertThat(or.getOr().getCondsCount()).isEqualTo(2); + assertThat(or.getOr().getConds(0).getCondCase()) + .isEqualTo(HranaProto.BatchCond.CondCase.STEP_ERROR); + assertThat(or.getOr().getConds(1).getCondCase()) + .isEqualTo(HranaProto.BatchCond.CondCase.IS_AUTOCOMMIT); + + assertThat(protoBatch.getSteps(5).getCondition().getCondCase()) + .isEqualTo(HranaProto.BatchCond.CondCase.IS_AUTOCOMMIT); + } + + @Test + void deserializesHelloOk() throws IOException { + WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setHelloOk(HranaWsProto.HelloOkMsg.getDefaultInstance()) + .build()); + + assertThat(msg).isEqualTo(WsServerMsg.HelloOk.INSTANCE); + } + + @Test + void deserializesHelloErrorWithAndWithoutCode() throws IOException { + WsServerMsg withCode = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setHelloError(HranaWsProto.HelloErrorMsg.newBuilder() + .setError(HranaProto.Error.newBuilder() + .setMessage("bad jwt") + .setCode("AUTH"))) + .build()); + assertThat(withCode).isEqualTo( + new WsServerMsg.HelloError(new HranaError("bad jwt", "AUTH"))); + + WsServerMsg withoutCode = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setHelloError(HranaWsProto.HelloErrorMsg.newBuilder() + .setError(HranaProto.Error.newBuilder().setMessage("bad jwt"))) + .build()); + assertThat(withoutCode).isEqualTo( + new WsServerMsg.HelloError(new HranaError("bad jwt", null))); + } + + @Test + void deserializesOpenAndCloseStreamResponses() throws IOException { + assertThat(deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(1) + .setOpenStream(HranaWsProto.OpenStreamResp.getDefaultInstance())) + .build())) + .isEqualTo(new WsServerMsg.ResponseOk(1, WsResponse.OpenStream.INSTANCE)); + + assertThat(deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(2) + .setCloseStream(HranaWsProto.CloseStreamResp.getDefaultInstance())) + .build())) + .isEqualTo(new WsServerMsg.ResponseOk(2, WsResponse.CloseStream.INSTANCE)); + } + + @Test + void deserializesExecuteResponseWithEveryValueType() throws IOException { + HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder() + .addCols(HranaProto.Col.newBuilder().setName("id").setDecltype("INTEGER")) + .addCols(HranaProto.Col.newBuilder().setName("value")) + .addRows(HranaProto.Row.newBuilder() + .addValues(HranaProto.Value.newBuilder().setNull( + HranaProto.Value.Null.getDefaultInstance())) + .addValues(HranaProto.Value.newBuilder().setInteger(Long.MIN_VALUE)) + .addValues(HranaProto.Value.newBuilder().setInteger(Long.MAX_VALUE)) + .addValues(HranaProto.Value.newBuilder().setFloat(1.5)) + .addValues(HranaProto.Value.newBuilder().setText( + "h\u00e9llo \u4e16\u754c \ud83d\ude00"))) + .addRows(HranaProto.Row.newBuilder() + .addValues(HranaProto.Value.newBuilder().setBlob(ByteString.EMPTY)) + .addValues(HranaProto.Value.newBuilder().setBlob(bytes(1, 2, 3)))) + .setAffectedRowCount(2) + .build(); + + WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(11) + .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result))) + .build()); + + StmtResult expected = new StmtResult( + List.of(new Col("id", "INTEGER"), new Col("value", null)), + List.of( + new Row(List.of( + Value.Null.INSTANCE, + new Value.Integer(Long.MIN_VALUE), + new Value.Integer(Long.MAX_VALUE), + new Value.Float(1.5), + new Value.Text("h\u00e9llo \u4e16\u754c \ud83d\ude00"))), + new Row(List.of( + new Value.Blob(new byte[0]), + new Value.Blob(new byte[] {1, 2, 3})))), + 2, + null); + + assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk(11, new WsResponse.Execute(expected))); + } + + @Test + void deserializesExecuteResponseWithLastInsertRowid() throws IOException { + HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder() + .setAffectedRowCount(1) + .setLastInsertRowid(Long.MIN_VALUE) + .build(); + + WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(12) + .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result))) + .build()); + + assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk( + 12, + new WsResponse.Execute( + new StmtResult(List.of(), List.of(), 1, Long.MIN_VALUE)))); + } + + @Test + void deserializesBatchResponseMaps() throws IOException { + HranaProto.BatchResult result = HranaProto.BatchResult.newBuilder() + .putStepResults(0, HranaProto.StmtResult.newBuilder() + .setAffectedRowCount(1) + .setLastInsertRowid(42) + .build()) + .putStepErrors(1, HranaProto.Error.newBuilder() + .setMessage("UNIQUE constraint failed: t.id") + .setCode("SQLITE_CONSTRAINT") + .build()) + .build(); + + WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(13) + .setBatch(HranaWsProto.BatchResp.newBuilder().setResult(result))) + .build()); + + BatchResult expected = new BatchResult( + Map.of(0, new StmtResult(List.of(), List.of(), 1, 42L)), + Map.of(1, new HranaError("UNIQUE constraint failed: t.id", "SQLITE_CONSTRAINT"))); + + assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk(13, new WsResponse.Batch(expected))); + } + + @Test + void deserializesResponseError() throws IOException { + WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder() + .setResponseError(HranaWsProto.ResponseErrorMsg.newBuilder() + .setRequestId(14) + .setError(HranaProto.Error.newBuilder() + .setMessage("no such table: t") + .setCode("SQLITE_ERROR"))) + .build()); + + assertThat(msg).isEqualTo(new WsServerMsg.ResponseError( + 14, new HranaError("no such table: t", "SQLITE_ERROR"))); + } + + @Test + void rejectsMessagesWithoutAPayload() { + HranaWsProto.ServerMsg empty = HranaWsProto.ServerMsg.getDefaultInstance(); + assertThatThrownBy(() -> deserialize(empty)) + .isInstanceOf(IOException.class) + .hasMessageContaining("msg case"); + + HranaProto.Value valueWithoutCase = HranaProto.Value.getDefaultInstance(); + HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder() + .addRows(HranaProto.Row.newBuilder().addValues(valueWithoutCase)) + .build(); + HranaWsProto.ServerMsg response = HranaWsProto.ServerMsg.newBuilder() + .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder() + .setRequestId(15) + .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result))) + .build(); + assertThatThrownBy(() -> deserialize(response)) + .isInstanceOf(IOException.class) + .hasMessageContaining("value case"); + } + + @Test + void serializesUtf8TextWithoutReplacement() throws IOException { + String unicode = "gr\u00fc\u00dfe \u65e5\u672c\u8a9e \u20ac"; + HranaWsProto.ClientMsg msg = serialize( + new WsClientMsg.Request(16, new WsRequest.Execute(1, new Stmt( + "SELECT ?", + null, + List.of(new Value.Text(unicode)), + List.of(), + true)))); + + String decoded = msg.getRequest().getExecute().getStmt().getArgs(0).getText(); + assertThat(decoded).isEqualTo(unicode); + assertThat(decoded.getBytes(StandardCharsets.UTF_8)) + .isEqualTo(unicode.getBytes(StandardCharsets.UTF_8)); + } +} From fe5bc461cf2b1945760e5aa920ceb8a5df3fcfe7 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 5/6] test(jdbc): cover encoding URL parsing and Protobuf transport validation --- .../libsql/jdbc/LibsqlUrlParserTest.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index d295da9..8ac8070 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -7,6 +7,7 @@ import java.util.Properties; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; public class LibsqlUrlParserTest { @@ -69,6 +70,45 @@ void testTlsKeepsAlreadySecureSchemes() throws SQLException { .isEqualTo("wss://example.com"); } + @Test + void testParseEncodesWebSocketEncoding() throws SQLException { + assertThat(LibsqlUrlParser.parse( + "jdbc:libsql:ws://localhost:8080?encoding=protobuf", new Properties()) + .encoding()) + .isEqualTo(LibsqlClientConfig.Encoding.PROTOBUF); + assertThat(LibsqlUrlParser.parse( + "jdbc:libsql:wss://example.com?encoding=PROTO", new Properties()) + .encoding()) + .isEqualTo(LibsqlClientConfig.Encoding.PROTOBUF); + assertThat(LibsqlUrlParser.parse( + "jdbc:libsql:ws://localhost:8080?encoding=json", new Properties()) + .encoding()) + .isEqualTo(LibsqlClientConfig.Encoding.JSON); + assertThat(LibsqlUrlParser.parse("jdbc:libsql:ws://localhost:8080", new Properties()) + .encoding()) + .isEqualTo(LibsqlClientConfig.Encoding.JSON); + } + + @Test + void testRejectsUnknownEncoding() { + assertThatThrownBy(() -> LibsqlUrlParser.parse( + "jdbc:libsql:ws://localhost:8080?encoding=msgpack", new Properties())) + .isInstanceOf(SQLException.class) + .hasMessageContaining("msgpack"); + } + + @Test + void testRejectsProtobufOnHttpTransport() { + assertThatThrownBy(() -> LibsqlUrlParser.parse( + "jdbc:libsql:http://localhost:8080?encoding=protobuf", new Properties())) + .isInstanceOf(SQLException.class) + .hasMessage("encoding=protobuf is only supported for ws/wss URLs"); + assertThatThrownBy(() -> LibsqlUrlParser.parse( + "jdbc:libsql:https://example.com?encoding=protobuf", new Properties())) + .isInstanceOf(SQLException.class) + .hasMessage("encoding=protobuf is only supported for ws/wss URLs"); + } + @Test void testAcceptsWebSocketUrlForms() { assertThat(LibsqlUrlParser.acceptsUrl("jdbc:libsql:ws://localhost:8080")).isTrue(); From f5e0b41cfbc48bf1cf5fd2c6fc72f1f8924818c4 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:54:20 +0200 Subject: [PATCH 6/6] test(itest): cover JDBC over the Protobuf WebSocket transport --- .../itest/WebSocketProtobufTransportIT.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java new file mode 100644 index 0000000..1d82ff3 --- /dev/null +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java @@ -0,0 +1,86 @@ +package com.github.magnusp.libsql.itest; + +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Drives the JDBC driver over Hrana 3 Protobuf on a WebSocket ({@code hrana3-protobuf} + * subprotocol). The observable JDBC behavior must match the JSON transport. + */ +public class WebSocketProtobufTransportIT extends SqldContainerBase { + + private static Connection connect() throws SQLException { + return DriverManager.getConnection( + "jdbc:libsql:" + getWebSocketUrl() + "?encoding=protobuf"); + } + + @Test + void selectReturnsTypedValuesIncluding64BitIntegerAndBlob() throws SQLException { + try (Connection connection = connect(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT 9007199254740993 AS big, -9223372036854775808 AS min," + + " 1.5 AS f, 'hello' AS t, x'DEADBEEF' AS b, NULL AS n")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong("big")).isEqualTo(9007199254740993L); + assertThat(resultSet.getLong("min")).isEqualTo(Long.MIN_VALUE); + assertThat(resultSet.getDouble("f")).isEqualTo(1.5); + assertThat(resultSet.getString("t")).isEqualTo("hello"); + assertThat(resultSet.getBytes("b")).containsExactly(0xDE, 0xAD, 0xBE, 0xEF); + assertThat(resultSet.getObject("n")).isNull(); + assertThat(resultSet.next()).isFalse(); + } + } + + @Test + void transactionCommitsAndRollsBackOnOneStream() throws SQLException { + try (Connection connection = connect()) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE ws_proto_tx (id INTEGER PRIMARY KEY, name TEXT)"); + } + + connection.setAutoCommit(false); + try (Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO ws_proto_tx VALUES (1, 'committed')"); + } + connection.commit(); + + try (Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO ws_proto_tx VALUES (2, 'rolled back')"); + } + connection.rollback(); + connection.setAutoCommit(true); + + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT id, name FROM ws_proto_tx ORDER BY id")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + assertThat(resultSet.getString(2)).isEqualTo("committed"); + assertThat(resultSet.next()).isFalse(); + } + } + } + + @Test + void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQLException { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + assertThatThrownBy(() -> statement.executeQuery("SELECT * FROM ws_proto_missing_table")) + .isInstanceOf(SQLException.class) + .hasMessageContaining("no such table"); + + try (ResultSet resultSet = statement.executeQuery("SELECT 40 + 2")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(42); + } + } + } +}