From ec21e9de138612f38211174bd275f3dbe7b7c1a0 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:21:15 +0100 Subject: [PATCH 1/3] feat(jdbc): accept transactionMode in the JDBC URL and connection properties The mode selects the begin SQL for the next transaction, so a write transaction can take its lock at BEGIN instead of failing a read-to-write upgrade with SQLITE_BUSY_SNAPSHOT. The value is connection behaviour, not transport config, so it travels in a ParsedConnection alongside the client config rather than in LibsqlClientConfig. --- README.md | 14 +++ .../libsql/itest/SqldTransactionSupport.java | 71 +++++++++++ .../libsql/itest/TransactionLifecycleIT.java | 57 +-------- .../libsql/itest/TransactionModeIT.java | 55 ++++++++ .../magnusp/libsql/jdbc/LibsqlConnection.java | 11 +- .../magnusp/libsql/jdbc/LibsqlDriver.java | 6 +- .../magnusp/libsql/jdbc/LibsqlUrlParser.java | 28 ++++- .../magnusp/libsql/jdbc/ParsedConnection.java | 5 + .../LibsqlConnectionTransactionModeTest.java | 117 ++++++++++++++++++ .../jdbc/LibsqlUrlParserPropertyTest.java | 30 +++++ .../libsql/jdbc/LibsqlUrlParserTest.java | 86 +++++++++++++ 11 files changed, 421 insertions(+), 59 deletions(-) create mode 100644 libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java create mode 100644 libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java create mode 100644 libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java diff --git a/README.md b/README.md index 9aadbc9..3fd57a1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ public class Example { } ``` +### Connection URL parameters + +The JDBC URL accepts these query parameters. The same keys work as `Properties` passed to +`DriverManager.getConnection`; a query parameter wins over the matching property. + +| Parameter | Values | Default | Notes | +| --- | --- | --- | --- | +| `authToken` | token | none | Sent as a bearer token. `jwt` and `password` are accepted aliases. | +| `busyTimeout` | milliseconds | `5000` | How long a request retries while the server reports busy. | +| `connectTimeout` | milliseconds | `10000` | HTTP connect and request timeout. | +| `encoding` | `json`, `protobuf` | `json` | `protobuf` requires a `ws://` or `wss://` URL. | +| `transactionMode` | `deferred`, `immediate`, `exclusive` | `deferred` | Mode used by the next transaction begin. Case-insensitive; an unknown value fails at connect. | +| `tls`, `ssl` | `true` | unset | Upgrades `http` to `https` and `ws` to `wss`. | + ## Development & Building ### Prerequisites diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java new file mode 100644 index 0000000..07a3776 --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java @@ -0,0 +1,71 @@ +package io.github.magnusp.libsql.itest; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +final class SqldTransactionSupport { + + private SqldTransactionSupport() {} + + static void createTable(Connection connection, String table) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"); + } + } + + static int insertRow(Connection connection, String table, int id) throws SQLException { + try (Statement statement = connection.createStatement()) { + return statement.executeUpdate("INSERT INTO " + table + " (id) VALUES (" + id + ")"); + } + } + + static void assertWriteIsBlockedUntilReleased( + String url, Connection lockHolder, String table, int id) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future insert = executor.submit(() -> { + try (Connection writer = DriverManager.getConnection(url)) { + return insertRow(writer, table, id); + } catch (SQLException e) { + return e; + } + }); + + Object resultWhileLocked; + try { + resultWhileLocked = insert.get(300, TimeUnit.MILLISECONDS); + assertThat(resultWhileLocked) + .as("writer must not succeed while the write lock is held") + .isNotEqualTo(1); + } catch (TimeoutException blocked) { + resultWhileLocked = null; + } + + lockHolder.setAutoCommit(true); + + if (insert.isDone()) { + Object completed = insert.get(); + if (completed instanceof SQLException) { + try (Connection writer = java.sql.DriverManager.getConnection(url)) { + assertThat(insertRow(writer, table, id)).isEqualTo(1); + } + } else { + assertThat(completed).isEqualTo(1); + } + } else { + assertThat(insert.get(20, TimeUnit.SECONDS)).isEqualTo(1); + } + } finally { + executor.shutdownNow(); + } + } +} diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java index 1cf930b..853b2e0 100644 --- a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java @@ -14,8 +14,10 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.assertWriteIsBlockedUntilReleased; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.createTable; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.insertRow; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -195,57 +197,4 @@ private static void assertMappedUniqueViolation(ThrowableAssert.ThrowingCallable .hasMessageContaining("UNIQUE constraint failed") .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); } - - private static void assertWriteIsBlockedUntilReleased( - String url, Connection lockHolder, String table, int id) throws Exception { - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future insert = executor.submit(() -> { - try (Connection writer = DriverManager.getConnection(url)) { - return insertRow(writer, table, id); - } catch (SQLException e) { - return e; - } - }); - - Object resultWhileLocked; - try { - resultWhileLocked = insert.get(300, TimeUnit.MILLISECONDS); - assertThat(resultWhileLocked) - .as("writer must not succeed while the write lock is held") - .isNotEqualTo(1); - } catch (TimeoutException blocked) { - resultWhileLocked = null; - } - - lockHolder.setAutoCommit(true); - - if (insert.isDone()) { - Object completed = insert.get(); - if (completed instanceof SQLException) { - try (Connection writer = DriverManager.getConnection(url)) { - assertThat(insertRow(writer, table, id)).isEqualTo(1); - } - } else { - assertThat(completed).isEqualTo(1); - } - } else { - assertThat(insert.get(20, TimeUnit.SECONDS)).isEqualTo(1); - } - } finally { - executor.shutdownNow(); - } - } - - private static void createTable(Connection connection, String table) throws SQLException { - try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"); - } - } - - private static int insertRow(Connection connection, String table, int id) throws SQLException { - try (Statement statement = connection.createStatement()) { - return statement.executeUpdate("INSERT INTO " + table + " (id) VALUES (" + id + ")"); - } - } } diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java new file mode 100644 index 0000000..763e9ce --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java @@ -0,0 +1,55 @@ +package io.github.magnusp.libsql.itest; + +import io.github.magnusp.libsql.jdbc.LibsqlConnection; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.assertWriteIsBlockedUntilReleased; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.createTable; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.insertRow; +import static org.assertj.core.api.Assertions.assertThat; + +public class TransactionModeIT extends SqldContainerBase { + + @Test + void urlImmediateTransactionModeHoldsTheWriteLockFromTheFirstStatement() throws Exception { + String url = "jdbc:libsql:" + getHttpUrl() + "?transactionMode=immediate"; + try (Connection lockHolder = DriverManager.getConnection(url); + Statement statement = lockHolder.createStatement()) { + createTable(lockHolder, "url_immediate_lock"); + assertThat(lockHolder.unwrap(LibsqlConnection.class).getTransactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + + lockHolder.setAutoCommit(false); + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM url_immediate_lock")) { + assertThat(rows.next()).isTrue(); + } + + assertWriteIsBlockedUntilReleased(url, lockHolder, "url_immediate_lock", 1); + } + } + + @Test + void urlDeferredTransactionModeDoesNotHoldTheWriteLockFromTheFirstStatement() throws Exception { + String url = "jdbc:libsql:" + getHttpUrl() + "?transactionMode=deferred"; + try (Connection lockHolder = DriverManager.getConnection(url); + Statement statement = lockHolder.createStatement(); + Connection writer = DriverManager.getConnection(url)) { + createTable(lockHolder, "url_deferred_lock"); + assertThat(lockHolder.unwrap(LibsqlConnection.class).getTransactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + + lockHolder.setAutoCommit(false); + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM url_deferred_lock")) { + assertThat(rows.next()).isTrue(); + } + + assertThat(insertRow(writer, "url_deferred_lock", 1)).isEqualTo(1); + lockHolder.rollback(); + } + } +} diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java index 9baeeff..1574444 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -68,7 +68,7 @@ public class LibsqlConnection implements Connection { private boolean readOnly = false; private int transactionIsolation = TRANSACTION_SERIALIZABLE; private boolean closed = false; - private TransactionMode transactionMode = TransactionMode.DEFERRED; + private TransactionMode transactionMode; private LibsqlDatabaseMetaData meta; private int nextSavepointId = 0; private boolean transactionActive = false; @@ -112,9 +112,18 @@ public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client) { } public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client, String jdbcUrl) { + this(config, client, jdbcUrl, TransactionMode.DEFERRED); + } + + LibsqlConnection( + LibsqlClientConfig config, + LibsqlClient client, + String jdbcUrl, + TransactionMode transactionMode) { this.config = config; this.client = client; this.jdbcUrl = jdbcUrl; + this.transactionMode = transactionMode; } public LibsqlClientConfig getConfig() { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java index 4fab91e..34aeb2e 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java @@ -35,7 +35,8 @@ public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) { return null; } - LibsqlClientConfig config = LibsqlUrlParser.parse(url, info); + ParsedConnection parsed = LibsqlUrlParser.parseConnection(url, info); + LibsqlClientConfig config = parsed.client(); LibsqlClient client; if (isWebSocketUrl(config.url())) { HranaWsCodec codec = config.encoding() == LibsqlClientConfig.Encoding.PROTOBUF @@ -45,7 +46,8 @@ public Connection connect(String url, Properties info) throws SQLException { } else { client = new LibsqlHttpClient(config); } - return new LibsqlConnection(config, client, LibsqlUrlParser.metadataUrl(url)); + return new LibsqlConnection( + config, client, LibsqlUrlParser.metadataUrl(url), parsed.transactionMode()); } private static boolean isWebSocketUrl(String url) { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index ffa8611..af4c02f 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -33,6 +33,10 @@ public static boolean acceptsUrl(String url) { } public static LibsqlClientConfig parse(String url, Properties info) throws SQLException { + return parseConnection(url, info).client(); + } + + static ParsedConnection parseConnection(String url, Properties info) throws SQLException { if (!acceptsUrl(url)) { throw new SQLException("Invalid JDBC URL: " + url); } @@ -45,8 +49,10 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx resolveDuration("connectTimeout", queryParams, info, LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT); Duration busyTimeout = resolveDuration("busyTimeout", queryParams, info, LibsqlClientConfig.DEFAULT_BUSY_TIMEOUT); - return new LibsqlClientConfig( - buildEndpoint(uri, scheme), authToken, connectTimeout, busyTimeout, encoding); + LibsqlConnection.TransactionMode transactionMode = resolveTransactionMode(queryParams, info); + return new ParsedConnection( + new LibsqlClientConfig(buildEndpoint(uri, scheme), authToken, connectTimeout, busyTimeout, encoding), + transactionMode); } /** @@ -158,6 +164,24 @@ private static Duration resolveDuration( return defaultValue; } + private static LibsqlConnection.TransactionMode resolveTransactionMode( + Map queryParams, Properties info) throws SQLException { + String value = queryParams.get("transactionMode"); + if (value == null && info != null) { + value = info.getProperty("transactionMode"); + } + if (value == null) { + return LibsqlConnection.TransactionMode.DEFERRED; + } + return switch (value.toLowerCase(Locale.ROOT)) { + case "deferred" -> LibsqlConnection.TransactionMode.DEFERRED; + case "immediate" -> LibsqlConnection.TransactionMode.IMMEDIATE; + case "exclusive" -> LibsqlConnection.TransactionMode.EXCLUSIVE; + default -> throw new SQLException("Unsupported transactionMode '" + value + + "': expected deferred, immediate, or exclusive"); + }; + } + /** Keeps a bad timeout a JDBC error instead of leaking {@link NumberFormatException}. */ private static Duration parseDuration(String name, String value) throws SQLException { try { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java new file mode 100644 index 0000000..5991462 --- /dev/null +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java @@ -0,0 +1,5 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlClientConfig; + +record ParsedConnection(LibsqlClientConfig client, LibsqlConnection.TransactionMode transactionMode) {} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java new file mode 100644 index 0000000..826824b --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java @@ -0,0 +1,117 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlClient; +import io.github.magnusp.libsql.client.LibsqlClientConfig; +import io.github.magnusp.libsql.hrana.codec.Batch; +import io.github.magnusp.libsql.hrana.codec.BatchResult; +import io.github.magnusp.libsql.hrana.codec.Stmt; +import io.github.magnusp.libsql.hrana.codec.StmtResult; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LibsqlConnectionTransactionModeTest { + + private static final LibsqlClientConfig CONFIG = new LibsqlClientConfig("http://localhost:1", null); + + @Test + void immediateConnectionBeginsItsFirstTransactionImmediately() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection( + CONFIG, client, null, LibsqlConnection.TransactionMode.IMMEDIATE); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN IMMEDIATE"); + assertThat(connection.getTransactionMode()).isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + } + + @Test + void exclusiveConnectionBeginsItsFirstTransactionExclusively() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection( + CONFIG, client, null, LibsqlConnection.TransactionMode.EXCLUSIVE); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN EXCLUSIVE"); + } + + @Test + void connectionsWithoutAnExplicitModeBeginDeferred() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN"); + assertThat(connection.getTransactionMode()).isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + } + + private static final class RecordingClient implements LibsqlClient { + + private final List sent = new ArrayList<>(); + private boolean openStream; + + @Override + public StmtResult executeOneShot(Stmt stmt) { + return emptyResult(); + } + + @Override + public StmtResult executeOnStream(Stmt stmt) { + sent.add(stmt.sql()); + openStream = true; + return emptyResult(); + } + + @Override + public BatchResult executeBatchOneShot(Batch batch) { + return emptyBatch(batch); + } + + @Override + public BatchResult executeBatchOnStream(Batch batch) { + openStream = true; + return emptyBatch(batch); + } + + @Override + public boolean hasOpenStream() { + return openStream; + } + + @Override + public void closeStream() { + openStream = false; + } + + @Override + public void close() { + openStream = false; + } + + private static StmtResult emptyResult() { + return new StmtResult(List.of(), List.of(), 0, null); + } + + private static BatchResult emptyBatch(Batch batch) { + Map results = new LinkedHashMap<>(); + for (int i = 0; i < batch.steps().size(); i++) { + if (batch.steps().get(i).stmt() != null) { + results.put(i, emptyResult()); + } + } + return new BatchResult(results, Map.of()); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java index 6c9da6a..e73be6b 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java @@ -73,6 +73,24 @@ void parseNeverThrowsAnUnexpectedExceptionFromProperties() { }); } + @Test + void parseNeverThrowsAnUnexpectedExceptionFromTransactionMode() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(transactionModeValues(), value -> { + Properties properties = new Properties(); + properties.setProperty("transactionMode", value); + try { + LibsqlUrlParser.parseConnection("jdbc:libsql:http://localhost:8080", properties); + } catch (SQLException expected) { + // A rejected mode is a caller error, not a driver crash. + } catch (RuntimeException unexpected) { + throw new AssertionError("unexpected " + unexpected + " for " + value, unexpected); + } + return true; + }); + } + @Test void metadataUrlIsIdempotent() { PropertyChecker.customized() @@ -97,6 +115,14 @@ private static Generator timeoutValues() { Generator.constant("999999999999999999999999")); } + private static Generator transactionModeValues() { + return Generator.anyOf( + Generator.sampledFrom("deferred", "immediate", "exclusive", "DEFERRED", "Immediate", "EXCLUSIVE"), + Generator.stringsOf(IntDistribution.uniform(0, 12), Generator.charsInRange((char) 0, (char) 127)), + Generator.constant(""), + Generator.constant("fast")); + } + private static Generator arbitraryUrls() { Generator fragments = Generator.anyOf( Generator.constant("jdbc:libsql:"), @@ -110,6 +136,10 @@ private static Generator arbitraryUrls() { Generator.constant("token"), Generator.constant("jwt"), Generator.constant("connectTimeout"), + Generator.constant("transactionMode"), + Generator.constant("deferred"), + Generator.constant("immediate"), + Generator.constant("exclusive"), Generator.constant("user:pass@"), Generator.stringsOf(IntDistribution.uniform(0, 8), Generator.charsInRange((char) 0, (char) 127))); return Generator.listsOf(IntDistribution.uniform(0, 10), fragments) diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index 76ad967..0090c6b 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -104,6 +104,84 @@ void testTlsKeepsAlreadySecureSchemes() throws SQLException { .isEqualTo("wss://example.com"); } + @Test + void testParseTransactionModeFromTheUrl() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=deferred") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=immediate") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=exclusive") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testParseTransactionModeIsCaseInsensitive() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=ImMeDiAtE") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=EXCLUSIVE") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testParseTransactionModeDefaultsToDeferred() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080").transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + } + + @Test + void testTransactionModeFromTheUrlBeatsProperties() throws SQLException { + Properties properties = new Properties(); + properties.setProperty("transactionMode", "exclusive"); + + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=immediate", properties) + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + } + + @Test + void testTransactionModeFallsBackToProperties() throws SQLException { + Properties properties = new Properties(); + properties.setProperty("transactionMode", "EXCLUSIVE"); + + assertThat(parseConnection("jdbc:libsql:http://localhost:8080", properties).transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testRejectsUnknownTransactionMode() { + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=fast")) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode 'fast': expected deferred, immediate, or exclusive"); + + Properties properties = new Properties(); + properties.setProperty("transactionMode", "fast"); + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080", properties)) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode 'fast': expected deferred, immediate, or exclusive"); + } + + @Test + void testRejectsEmptyTransactionMode() { + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=")) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode '': expected deferred, immediate, or exclusive"); + } + + @Test + void testPublicParseReturnsTheParsedClientConfig() throws SQLException { + String url = "jdbc:libsql:http://localhost:8080/dev/tenant?authToken=secretToken" + + "&connectTimeout=2500&transactionMode=immediate"; + + assertThat(LibsqlUrlParser.parse(url, new Properties())) + .isEqualTo(parseConnection(url).client()); + assertThat(LibsqlUrlParser.parse(url, new Properties()).authToken()).isEqualTo("secretToken"); + } + @Test void testParseEncodesWebSocketEncoding() throws SQLException { assertThat(LibsqlUrlParser.parse( @@ -186,4 +264,12 @@ void testMetadataUrlLeavesANonCredentialUrlUnchanged() { String url = "jdbc:sqlite:http://localhost:8080/dev/tenant?connectTimeout=5000"; assertThat(LibsqlUrlParser.metadataUrl(url)).isEqualTo(url); } + + private static ParsedConnection parseConnection(String url) throws SQLException { + return parseConnection(url, new Properties()); + } + + private static ParsedConnection parseConnection(String url, Properties properties) throws SQLException { + return LibsqlUrlParser.parseConnection(url, properties); + } } From 5d77808c8e5090ef590cb02d08ee8fa8700f35eb Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:27:23 +0100 Subject: [PATCH 2/3] fix(client): surface libSQL auth refusals and reset the stream sqld answers a forbidden write with HTTP 403 and {"error":"..."}, which the codecs read as an empty Hrana message. Treating the error field as the message keeps the server's refusal text, and preserving the HTTP status lets LibsqlErrorMapper report SQLState 28000 instead of HY000. The server invalidates the baton with the refusal, so clearing it on 401/403 lets the next statement open a fresh stream rather than replay a dead one. --- .../libsql/client/LibsqlHttpClient.java | 29 +++- .../client/LibsqlHttpClientAuthTest.java | 102 ++++++++++++ .../hrana/codec/DirectHranaJsonCodec.java | 13 +- .../hrana/codec/JacksonHranaJsonCodec.java | 14 +- .../hrana/codec/DirectHranaJsonCodecTest.java | 22 +++ .../magnusp/libsql/itest/AuthTokenIT.java | 154 ++++++++++++++++++ .../libsql/jdbc/LibsqlErrorMapperTest.java | 24 +++ 7 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java create mode 100644 libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java diff --git a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java index 0132f89..eb2b7f6 100644 --- a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java +++ b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java @@ -100,7 +100,13 @@ public StmtResult executeOnStream(Stmt stmt) { StreamResult result; synchronized (streamLock) { PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt))); - PipelineRespBody resp = sendPipeline(req, baseUrl); + PipelineRespBody resp; + try { + resp = sendPipeline(req, baseUrl); + } catch (LibsqlException e) { + clearStreamOnAuthFailure(e); + throw e; + } updateStreamState(resp); result = firstResult(resp); } @@ -135,7 +141,13 @@ public BatchResult executeBatchOnStream(Batch batch) { StreamResult result; synchronized (streamLock) { PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.BatchReq(batch))); - PipelineRespBody resp = sendPipeline(req, baseUrl); + PipelineRespBody resp; + try { + resp = sendPipeline(req, baseUrl); + } catch (LibsqlException e) { + clearStreamOnAuthFailure(e); + throw e; + } updateStreamState(resp); result = firstResult(resp); } @@ -196,6 +208,17 @@ private void updateStreamState(PipelineRespBody resp) { } } + /** + * The server invalidates the baton when it refuses a request, so the next statement must open a + * fresh stream instead of replaying a dead one. Callers hold {@link #streamLock}. + */ + private void clearStreamOnAuthFailure(LibsqlException e) { + if (e.getHttpStatus() == 401 || e.getHttpStatus() == 403) { + baton = null; + baseUrl = null; + } + } + public PipelineRespBody sendPipeline(PipelineReqBody reqBody, URI targetUri) { return sendPipelineWithRetry(reqBody, targetUri != null ? targetUri : pipelineUri); } @@ -239,7 +262,7 @@ private PipelineRespBody sendPipelineWithRetry(PipelineReqBody reqBody, URI uri) } catch (Exception ignored) { } if (err != null) { - throw new LibsqlException(err); + throw new LibsqlException(err.message(), err.code(), status); } throw new LibsqlException("HTTP error " + status + ": " + new String(response.body()), null, status); } diff --git a/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java b/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java new file mode 100644 index 0000000..28b116a --- /dev/null +++ b/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java @@ -0,0 +1,102 @@ +package io.github.magnusp.libsql.client; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import io.github.magnusp.libsql.hrana.codec.Stmt; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlHttpClientAuthTest { + + private static final byte[] FORBIDDEN = + "{\"error\":\"Authorization forbidden: nope\"}".getBytes(StandardCharsets.UTF_8); + + private static final byte[] STREAM_STARTED = + ("{\"baton\":\"baton-1\",\"results\":[{\"type\":\"ok\",\"response\":" + + "{\"type\":\"execute\",\"result\":{\"cols\":[],\"rows\":[]," + + "\"affected_row_count\":0,\"last_insert_rowid\":null}}}]}") + .getBytes(StandardCharsets.UTF_8); + + @Test + void forbiddenResponseSurfacesTheServerMessageAndStatus() throws Exception { + try (Server server = new Server(exchange -> respond(exchange, 403, FORBIDDEN))) { + LibsqlHttpClient client = server.client(); + + assertThatThrownBy(() -> client.executeOneShot(new Stmt("SELECT 1"))) + .isInstanceOfSatisfying(LibsqlException.class, e -> { + assertThat(e.getMessage()).isEqualTo("Authorization forbidden: nope"); + assertThat(e.getHttpStatus()).isEqualTo(403); + }); + } + } + + @Test + void forbiddenResponseOnAStreamDropsTheBaton() throws Exception { + AtomicInteger request = new AtomicInteger(); + try (Server server = new Server(exchange -> { + if (request.getAndIncrement() == 1) { + respond(exchange, 403, FORBIDDEN); + } else { + respond(exchange, 200, STREAM_STARTED); + } + })) { + LibsqlHttpClient client = server.client(); + client.executeOnStream(new Stmt("SELECT 1")); + assertThat(client.hasOpenStream()).isTrue(); + + assertThatThrownBy(() -> client.executeOnStream(new Stmt("INSERT INTO t VALUES (1)"))) + .isInstanceOfSatisfying( + LibsqlException.class, + e -> assertThat(e.getHttpStatus()).isEqualTo(403)); + assertThat(client.hasOpenStream()).isFalse(); + + client.executeOnStream(new Stmt("SELECT 2")); + assertThat(server.bodies.get(2)).doesNotContain("baton-1"); + } + } + + private static void respond(HttpExchange exchange, int status, byte[] body) throws IOException { + exchange.sendResponseHeaders(status, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + + private static final class Server implements AutoCloseable { + + private final HttpServer server; + private final List bodies = new CopyOnWriteArrayList<>(); + + Server(HttpHandler handler) throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/v3/pipeline", exchange -> { + bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + handler.handle(exchange); + }); + server.start(); + } + + LibsqlHttpClient client() { + LibsqlClientConfig config = new LibsqlClientConfig( + "http://127.0.0.1:" + server.getAddress().getPort(), null); + return new LibsqlHttpClient(config); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java index f05043a..2eca22b 100644 --- a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java +++ b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java @@ -570,17 +570,26 @@ private DescribeCol parseDescribeCol(FastJsonParser p) throws IOException { private HranaError parseError(FastJsonParser p) throws IOException { p.expectObjectStart(); - String message = ""; + String message = null; String code = null; while (!p.checkObjectEnd()) { String field = p.nextFieldName(); switch (field) { case "message" -> message = p.nextString(); + // A transport-level refusal carries only an "error" string, not a Hrana Error. + case "error" -> { + String text = p.peekString() ? p.nextString() : null; + if (text == null) { + p.skipValue(); + } else if (message == null) { + message = text; + } + } case "code" -> code = p.nextStringOrNull(); default -> p.skipValue(); } } - return new HranaError(message, code); + return new HranaError(message != null ? message : "", code); } // ------------------------------------------------------------- diff --git a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java index ef02197..b5f03c0 100644 --- a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java +++ b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java @@ -521,17 +521,27 @@ private HranaError readError(JsonParser p) throws IOException { if (p.currentToken() != JsonToken.START_OBJECT) { throw new IOException("Expected START_OBJECT for Error"); } - String message = ""; + String message = null; String code = null; while (p.nextToken() != JsonToken.END_OBJECT) { String field = p.currentName(); p.nextToken(); switch (field) { case "message" -> message = p.getText(); + // A transport-level refusal carries only an "error" string, not a Hrana Error. + case "error" -> { + if (p.currentToken() == JsonToken.VALUE_STRING) { + if (message == null) { + message = p.getText(); + } + } else { + p.skipChildren(); + } + } case "code" -> code = p.getText(); default -> p.skipChildren(); } } - return new HranaError(message, code); + return new HranaError(message != null ? message : "", code); } } diff --git a/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java index 112b0b9..09589a2 100644 --- a/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java +++ b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; @@ -48,4 +50,24 @@ void testSerializeSupplementaryCharactersAsUtf8() throws IOException { assertThat(json).doesNotContain("?"); } + @Test + void testDeserializeErrorFallsBackToTheErrorField() throws IOException { + HranaError error = codec.deserializeError(errorBody("{\"error\":\"Authorization forbidden: nope\"}")); + + assertThat(error.message()).isEqualTo("Authorization forbidden: nope"); + assertThat(error.code()).isNull(); + } + + @Test + void testDeserializeErrorPrefersTheMessageField() throws IOException { + HranaError error = codec.deserializeError( + errorBody("{\"message\":\"constraint failed\",\"error\":\"nope\",\"code\":\"SQLITE_CONSTRAINT\"}")); + + assertThat(error.message()).isEqualTo("constraint failed"); + assertThat(error.code()).isEqualTo("SQLITE_CONSTRAINT"); + } + + private static InputStream errorBody(String json) { + return new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + } } diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java new file mode 100644 index 0000000..b62a541 --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java @@ -0,0 +1,154 @@ +package io.github.magnusp.libsql.itest; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLInvalidAuthorizationSpecException; +import java.sql.Statement; +import java.time.Duration; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Testcontainers +public class AuthTokenIT { + + private static final int PORT = 8080; + private static final String PUBLIC_KEY_IN_CONTAINER = "/etc/sqld/libsql-jwt-public.pem"; + + private static final KeyPair KEY_PAIR = generateKeyPair(); + private static final String READ_ONLY_TOKEN = mintToken("ro"); + private static final String READ_WRITE_TOKEN = mintToken("rw"); + + @Container + private static final GenericContainer SQLD = new GenericContainer<>(SqldContainerBase.LIBSQL_IMAGE) + .withExposedPorts(PORT) + .withEnv("SQLD_NODE", "primary") + .withEnv("SQLD_AUTH_JWT_KEY_FILE", PUBLIC_KEY_IN_CONTAINER) + .withCopyFileToContainer(MountableFile.forHostPath(writePublicKey()), PUBLIC_KEY_IN_CONTAINER) + // Under auth the pipeline endpoint answers 401, so readiness comes from /health. + .waitingFor(Wait.forHttp("/health") + .forPort(PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(60))); + + @Test + void aRequestWithoutATokenIsRefused() throws Exception { + try (Connection connection = DriverManager.getConnection(url(null)); + Statement statement = connection.createStatement()) { + assertThatThrownBy(() -> statement.execute("SELECT 1")) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getSQLState()).isEqualTo("28000"); + }); + } + } + + @Test + void aReadOnlyTokenCanRead() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_ONLY_TOKEN)); + Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM auth_rows")) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getInt(1)).isGreaterThanOrEqualTo(0); + } + } + + @Test + void aReadOnlyTokenCannotWriteAndTheConnectionStillReads() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_ONLY_TOKEN)); + Statement statement = connection.createStatement()) { + assertThatThrownBy(() -> statement.executeUpdate("INSERT INTO auth_rows (id) VALUES (9001)")) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getSQLState()).isEqualTo("28000"); + assertThat(e.getMessage()).contains("Authorization forbidden"); + }); + + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM auth_rows")) { + assertThat(rows.next()).isTrue(); + } + } + } + + @Test + void aReadWriteTokenCanWrite() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_WRITE_TOKEN)); + Statement statement = connection.createStatement()) { + assertThat(statement.executeUpdate("INSERT OR REPLACE INTO auth_rows (id) VALUES (9002)")) + .isEqualTo(1); + } + } + + private static void createTable() throws SQLException { + try (Connection connection = DriverManager.getConnection(url(READ_WRITE_TOKEN)); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS auth_rows (id INTEGER PRIMARY KEY)"); + } + } + + private static String url(String token) { + String base = "jdbc:libsql:http://" + SQLD.getHost() + ":" + SQLD.getMappedPort(PORT); + return token == null ? base : base + "?authToken=" + token; + } + + private static KeyPair generateKeyPair() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Ed25519 is required to mint a sqld token", e); + } + } + + private static Path writePublicKey() { + String pem = "-----BEGIN PUBLIC KEY-----\n" + + Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(KEY_PAIR.getPublic().getEncoded()) + + "\n-----END PUBLIC KEY-----\n"; + try { + Path file = Files.createTempFile("libsql-jwt-", ".pem"); + file.toFile().deleteOnExit(); + // The copy keeps the source mode, and sqld runs as an unprivileged container user. + file.toFile().setReadable(true, false); + return Files.writeString(file, pem, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String mintToken(String role) { + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + String header = encoder.encodeToString( + "{\"alg\":\"EdDSA\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + String payload = encoder.encodeToString( + ("{\"a\":\"" + role + "\"}").getBytes(StandardCharsets.UTF_8)); + String signingInput = header + "." + payload; + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(KEY_PAIR.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + return signingInput + "." + encoder.encodeToString(signer.sign()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("could not mint a " + role + " token", e); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java new file mode 100644 index 0000000..5840f3d --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java @@ -0,0 +1,24 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlException; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.sql.SQLInvalidAuthorizationSpecException; + +import static org.assertj.core.api.Assertions.assertThat; + +class LibsqlErrorMapperTest { + + @Test + void aForbiddenHttpResponseMapsToAnAuthorizationFailure() { + SQLException mapped = LibsqlErrorMapper.toSQLException( + new LibsqlException("Authorization forbidden: nope", null, 403)); + + assertThat((Throwable) mapped) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getMessage()).isEqualTo("Authorization forbidden: nope"); + assertThat(e.getSQLState()).isEqualTo("28000"); + }); + } +} From a8757dc6cad322fb69d4315cf16f62dcccb51c87 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:30:04 +0100 Subject: [PATCH 3/3] docs: document the server-enforced read-only boundary --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 3fd57a1..ae5e173 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,12 @@ The JDBC URL accepts these query parameters. The same keys work as `Properties` | `transactionMode` | `deferred`, `immediate`, `exclusive` | `deferred` | Mode used by the next transaction begin. Case-insensitive; an unknown value fails at connect. | | `tls`, `ssl` | `true` | unset | Upgrades `http` to `https` and `ws` to `wss`. | +`Connection.setReadOnly` is the JDBC hint and does not reject writes. A connection is read-only +only when the server enforces it. With sqld, start the server with `--auth-jwt-key-file` and pass a +JWT whose `a` claim is `ro` as `authToken`; sqld then refuses writes on that connection. sqld +v0.24.33 refuses `PRAGMA query_only`, so there is no session-level setting that achieves the same +thing. + ## Development & Building ### Prerequisites