libsql2 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/Cargo.toml ADDED
@@ -0,0 +1,7 @@
1
+ [workspace]
2
+ members = ["ext/libsql2"]
3
+ resolver = "2"
4
+
5
+ [workspace.lints.clippy]
6
+ all = { level = "warn", priority = -1 }
7
+ needless_pass_by_value = "allow" # magnus callbacks require owned values
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 speria-jp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # libsql2
2
+
3
+ An alternative to [turso_libsql](https://github.com/tursodatabase/libsql-ruby) with a native Rust extension.
4
+
5
+ ## Features
6
+
7
+ - **Native Rust extension** — no FFI overhead
8
+ - **Full libSQL support** — local files, in-memory, remote (Turso Cloud), and Embedded Replicas
9
+
10
+ ## Installation
11
+
12
+ Add to your Gemfile:
13
+
14
+ ```ruby
15
+ gem "libsql2"
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Opening a Database
27
+
28
+ ```ruby
29
+ require "libsql2"
30
+
31
+ # Local file
32
+ db = Libsql::Database.open("/path/to/file.db")
33
+
34
+ # In-memory
35
+ db = Libsql::Database.open(":memory:")
36
+
37
+ # Remote (Turso Cloud)
38
+ db = Libsql::Database.open("libsql://xxx.turso.io", auth_token: "xxx")
39
+
40
+ # Embedded Replica
41
+ db = Libsql::Database.open("libsql://xxx.turso.io",
42
+ auth_token: "xxx",
43
+ local_path: "/path/to/replica.db",
44
+ sync_interval: 60,
45
+ read_your_writes: true
46
+ )
47
+
48
+ # Block form (auto-close)
49
+ Libsql::Database.open(":memory:") do |db|
50
+ # ...
51
+ end
52
+ ```
53
+
54
+ ### Querying
55
+
56
+ ```ruby
57
+ db.connect do |conn|
58
+ conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
59
+ conn.execute("INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", 30])
60
+
61
+ rows = conn.query("SELECT * FROM users WHERE age > ?", [20])
62
+ rows.columns #=> ["id", "name", "age"]
63
+ rows.types #=> ["INTEGER", "TEXT", "INTEGER"]
64
+ rows.each { |row| puts row["name"] }
65
+ end
66
+ ```
67
+
68
+ ### Execute (no result set)
69
+
70
+ ```ruby
71
+ conn.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
72
+ conn.changes #=> 1
73
+ conn.last_insert_rowid #=> 42
74
+ ```
75
+
76
+ ### Batch Execution
77
+
78
+ ```ruby
79
+ conn.execute_batch(<<~SQL)
80
+ CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
81
+ CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT);
82
+ SQL
83
+ ```
84
+
85
+ ### Prepared Statements
86
+
87
+ ```ruby
88
+ stmt = conn.prepare("SELECT * FROM users WHERE name = ?")
89
+ stmt.bind(["Alice"])
90
+ rows = stmt.query
91
+ stmt.close
92
+ ```
93
+
94
+ ### Transactions
95
+
96
+ ```ruby
97
+ conn.transaction do |tx|
98
+ tx.execute("UPDATE accounts SET balance = balance - 100 WHERE id = ?", [1])
99
+ tx.execute("UPDATE accounts SET balance = balance + 100 WHERE id = ?", [2])
100
+ # Commits on normal exit, rolls back on exception
101
+ end
102
+
103
+ # With behavior
104
+ conn.transaction(:immediate) do |tx|
105
+ tx.execute("INSERT INTO orders (item) VALUES (?)", ["widget"])
106
+ end
107
+
108
+ # Nested transactions (SAVEPOINT)
109
+ conn.transaction do |tx|
110
+ tx.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
111
+ tx.transaction do |tx2|
112
+ tx2.execute("INSERT INTO users (name) VALUES (?)", ["Bob"])
113
+ end
114
+ end
115
+ ```
116
+
117
+ ### Embedded Replica Sync
118
+
119
+ ```ruby
120
+ db.sync # Manual sync
121
+ ```
122
+
123
+ ### Fork Safety (Puma / Unicorn)
124
+
125
+ ```ruby
126
+ # puma.rb
127
+ on_worker_boot do
128
+ $db.discard! if $db
129
+ $db = Libsql::Database.open(...)
130
+ end
131
+ ```
132
+
133
+ ## Type Mapping
134
+
135
+ | libSQL Type | Ruby Type | Notes |
136
+ |---|---|---|
137
+ | INTEGER | `Integer` | Signed 64-bit |
138
+ | REAL | `Float` | Double precision |
139
+ | TEXT | `String` | UTF-8 encoded |
140
+ | BLOB | `Libsql::Blob` | `String` subclass with `ASCII-8BIT` encoding |
141
+ | NULL | `nil` | |
142
+
143
+ ## Development
144
+
145
+ Requirements: Ruby >= 3.1, Rust >= 1.74, and a C compiler.
146
+
147
+ ```bash
148
+ bundle install # Install Ruby dependencies
149
+ rake compile # Compile the Rust extension
150
+ rake spec # Run tests
151
+ rake lint # Run all linters (RuboCop + Clippy)
152
+ rake fmt # Auto-format all code
153
+ ```
154
+
155
+ ## Contributing
156
+
157
+ Bug reports and pull requests are welcome on GitHub at https://github.com/speria-jp/libsql-ruby2.
158
+
159
+ ## License
160
+
161
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,16 @@
1
+ [package]
2
+ name = "libsql2"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ crate-type = ["cdylib"]
9
+
10
+ [lints]
11
+ workspace = true
12
+
13
+ [dependencies]
14
+ magnus = "0.8"
15
+ libsql = "0.9"
16
+ tokio = { version = "1", features = ["rt-multi-thread"] }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("libsql/libsql2")
@@ -0,0 +1,239 @@
1
+ use std::cell::RefCell;
2
+ use std::sync::Arc;
3
+
4
+ use magnus::{prelude::*, Error, RArray, RModule, RString, Ruby, TryConvert, Value};
5
+ use tokio::runtime::Runtime;
6
+
7
+ use crate::error;
8
+ use crate::rows::Rows;
9
+ use crate::statement::Statement;
10
+
11
+ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
12
+ let class = module.define_class("Connection", ruby.class_object())?;
13
+ class.define_method("execute", magnus::method!(Connection::execute, -1))?;
14
+ class.define_method(
15
+ "execute_batch",
16
+ magnus::method!(Connection::execute_batch, 1),
17
+ )?;
18
+ class.define_method("query", magnus::method!(Connection::query, -1))?;
19
+ class.define_method("changes", magnus::method!(Connection::changes, 0))?;
20
+ class.define_method(
21
+ "last_insert_rowid",
22
+ magnus::method!(Connection::last_insert_rowid, 0),
23
+ )?;
24
+ class.define_method("prepare", magnus::method!(Connection::prepare, 1))?;
25
+ class.define_method("close", magnus::method!(Connection::close, 0))?;
26
+ class.define_method("closed?", magnus::method!(Connection::is_closed, 0))?;
27
+
28
+ Ok(())
29
+ }
30
+
31
+ /// Internal state dropped together on close to release file descriptors.
32
+ pub(crate) struct ConnectionInner {
33
+ pub rt: Arc<Runtime>,
34
+ pub conn: libsql::Connection,
35
+ }
36
+
37
+ #[magnus::wrap(class = "Libsql::Connection", free_immediately, size)]
38
+ pub struct Connection {
39
+ inner: RefCell<Option<ConnectionInner>>,
40
+ }
41
+
42
+ impl Connection {
43
+ pub fn new(rt: Arc<Runtime>, conn: libsql::Connection) -> Self {
44
+ Self {
45
+ inner: RefCell::new(Some(ConnectionInner { rt, conn })),
46
+ }
47
+ }
48
+
49
+ fn with_inner<F, T>(&self, f: F) -> Result<T, Error>
50
+ where
51
+ F: FnOnce(&ConnectionInner) -> Result<T, Error>,
52
+ {
53
+ let inner = self.inner.borrow();
54
+ let inner = inner
55
+ .as_ref()
56
+ .ok_or_else(|| Error::new(error::closed_error(), "connection is closed"))?;
57
+ f(inner)
58
+ }
59
+
60
+ fn execute(&self, args: &[Value]) -> Result<u64, Error> {
61
+ if args.is_empty() {
62
+ return Err(Error::new(
63
+ error::libsql_error(),
64
+ "wrong number of arguments (given 0, expected 1..2)",
65
+ ));
66
+ }
67
+ let sql: String = TryConvert::try_convert(args[0])?;
68
+ let params = if args.len() > 1 {
69
+ ruby_array_to_params(&args[1])?
70
+ } else {
71
+ vec![]
72
+ };
73
+
74
+ self.with_inner(|inner| {
75
+ inner
76
+ .rt
77
+ .block_on(async { inner.conn.execute(&sql, params_to_libsql(&params)).await })
78
+ .map_err(error::to_error)
79
+ })
80
+ }
81
+
82
+ fn execute_batch(&self, sql: String) -> Result<(), Error> {
83
+ self.with_inner(|inner| {
84
+ inner
85
+ .rt
86
+ .block_on(async { inner.conn.execute_batch(&sql).await })
87
+ .map_err(error::to_error)?;
88
+ Ok(())
89
+ })
90
+ }
91
+
92
+ fn query(&self, args: &[Value]) -> Result<Rows, Error> {
93
+ if args.is_empty() {
94
+ return Err(Error::new(
95
+ error::libsql_error(),
96
+ "wrong number of arguments (given 0, expected 1..2)",
97
+ ));
98
+ }
99
+ let sql: String = TryConvert::try_convert(args[0])?;
100
+ let params = if args.len() > 1 {
101
+ ruby_array_to_params(&args[1])?
102
+ } else {
103
+ vec![]
104
+ };
105
+
106
+ self.with_inner(|inner| {
107
+ inner.rt.block_on(async {
108
+ let stmt = inner.conn.prepare(&sql).await.map_err(error::to_error)?;
109
+ let (column_names, column_types) = extract_column_meta(&stmt);
110
+
111
+ let mut rows = stmt
112
+ .query(params_to_libsql(&params))
113
+ .await
114
+ .map_err(error::to_error)?;
115
+
116
+ let col_count = column_names.len();
117
+ let mut data = Vec::new();
118
+ while let Some(row) = rows.next().await.map_err(error::to_error)? {
119
+ let mut row_data = Vec::with_capacity(col_count);
120
+ for i in 0..col_count {
121
+ let val = row.get_value(i as i32).map_err(error::to_error)?;
122
+ row_data.push(val.into());
123
+ }
124
+ data.push(row_data);
125
+ }
126
+
127
+ Ok(Rows::new(column_names, column_types, data))
128
+ })
129
+ })
130
+ }
131
+
132
+ fn prepare(&self, sql: String) -> Result<Statement, Error> {
133
+ self.with_inner(|inner| {
134
+ inner.rt.block_on(async {
135
+ let stmt = inner.conn.prepare(&sql).await.map_err(error::to_error)?;
136
+ let (column_names, column_types) = extract_column_meta(&stmt);
137
+
138
+ Ok(Statement::new(
139
+ Arc::clone(&inner.rt),
140
+ stmt,
141
+ column_names,
142
+ column_types,
143
+ ))
144
+ })
145
+ })
146
+ }
147
+
148
+ fn changes(&self) -> Result<u64, Error> {
149
+ self.with_inner(|inner| Ok(inner.conn.changes()))
150
+ }
151
+
152
+ fn last_insert_rowid(&self) -> Result<i64, Error> {
153
+ self.with_inner(|inner| Ok(inner.conn.last_insert_rowid()))
154
+ }
155
+
156
+ fn close(&self) -> Result<(), Error> {
157
+ let _ = self.inner.borrow_mut().take();
158
+ Ok(())
159
+ }
160
+
161
+ fn is_closed(&self) -> bool {
162
+ self.inner.borrow().is_none()
163
+ }
164
+ }
165
+
166
+ /// Extract column names and declared types from a prepared statement.
167
+ pub(crate) fn extract_column_meta(stmt: &libsql::Statement) -> (Vec<String>, Vec<Option<String>>) {
168
+ let columns = stmt.columns();
169
+ let mut names = Vec::with_capacity(columns.len());
170
+ let mut types = Vec::with_capacity(columns.len());
171
+ for col in &columns {
172
+ names.push(col.name().to_string());
173
+ types.push(col.decl_type().map(|s| s.to_uppercase()));
174
+ }
175
+ (names, types)
176
+ }
177
+
178
+ /// Parameter value holder for conversion
179
+ pub(crate) enum ParamValue {
180
+ Null,
181
+ Integer(i64),
182
+ Real(f64),
183
+ Text(String),
184
+ Blob(Vec<u8>),
185
+ }
186
+
187
+ pub(crate) fn ruby_array_to_params(val: &Value) -> Result<Vec<ParamValue>, Error> {
188
+ let array: RArray = TryConvert::try_convert(*val)?;
189
+ let ruby = Ruby::get().expect("called from Ruby");
190
+ let mut params = Vec::with_capacity(array.len());
191
+ for item in array.into_iter() {
192
+ if item.is_nil() {
193
+ params.push(ParamValue::Null);
194
+ } else if item.is_kind_of(ruby.class_integer()) {
195
+ let i: i64 = TryConvert::try_convert(item)?;
196
+ params.push(ParamValue::Integer(i));
197
+ } else if item.is_kind_of(ruby.class_float()) {
198
+ let f: f64 = TryConvert::try_convert(item)?;
199
+ params.push(ParamValue::Real(f));
200
+ } else if is_blob(item) {
201
+ // Blob is a String subclass — must check before String
202
+ let rstr: RString = TryConvert::try_convert(item)?;
203
+ let bytes = unsafe { rstr.as_slice() }.to_vec();
204
+ params.push(ParamValue::Blob(bytes));
205
+ } else if item.is_kind_of(ruby.class_string()) {
206
+ let s: String = TryConvert::try_convert(item)?;
207
+ params.push(ParamValue::Text(s));
208
+ } else {
209
+ return Err(Error::new(
210
+ error::libsql_error(),
211
+ format!("unsupported parameter type: {}", unsafe {
212
+ item.classname()
213
+ }),
214
+ ));
215
+ }
216
+ }
217
+ Ok(params)
218
+ }
219
+
220
+ fn is_blob(val: Value) -> bool {
221
+ val.is_kind_of(crate::rows::blob_class())
222
+ }
223
+
224
+ pub(crate) fn params_to_libsql(params: &[ParamValue]) -> libsql::params::Params {
225
+ if params.is_empty() {
226
+ return libsql::params::Params::None;
227
+ }
228
+ let values: Vec<libsql::Value> = params
229
+ .iter()
230
+ .map(|p| match p {
231
+ ParamValue::Null => libsql::Value::Null,
232
+ ParamValue::Integer(i) => libsql::Value::Integer(*i),
233
+ ParamValue::Real(f) => libsql::Value::Real(*f),
234
+ ParamValue::Text(s) => libsql::Value::Text(s.clone()),
235
+ ParamValue::Blob(b) => libsql::Value::Blob(b.clone()),
236
+ })
237
+ .collect();
238
+ libsql::params::Params::Positional(values)
239
+ }
@@ -0,0 +1,241 @@
1
+ use std::cell::RefCell;
2
+ use std::sync::Arc;
3
+
4
+ use magnus::prelude::*;
5
+ use magnus::{Error, RHash, RModule, Ruby, TryConvert, Value};
6
+ use tokio::runtime::Runtime;
7
+
8
+ use crate::connection::Connection;
9
+ use crate::error;
10
+
11
+ pub fn init(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
12
+ let class = module.define_class("Database", ruby.class_object())?;
13
+ class.define_singleton_method("open", magnus::function!(Database::open, -1))?;
14
+ class.define_method("close", magnus::method!(Database::close, 0))?;
15
+ class.define_method("closed?", magnus::method!(Database::is_closed, 0))?;
16
+ class.define_method("sync", magnus::method!(Database::sync, 0))?;
17
+ class.define_method("connect", magnus::method!(Database::connect, 0))?;
18
+ class.define_method("discard!", magnus::method!(Database::discard, 0))?;
19
+
20
+ Ok(())
21
+ }
22
+
23
+ /// Internal state that gets dropped together on close/discard.
24
+ struct DatabaseInner {
25
+ rt: Arc<Runtime>,
26
+ db: libsql::Database,
27
+ }
28
+
29
+ #[magnus::wrap(class = "Libsql::Database", free_immediately, size)]
30
+ struct Database {
31
+ inner: RefCell<Option<DatabaseInner>>,
32
+ }
33
+
34
+ /// Extract an optional String value from a Ruby hash by symbol key.
35
+ fn hash_get_string(hash: &RHash, key: &str) -> Result<Option<String>, Error> {
36
+ let ruby = Ruby::get().expect("called from Ruby");
37
+ let sym = ruby.to_symbol(key);
38
+ let val: Option<Value> = hash.lookup(sym)?;
39
+ match val {
40
+ Some(v) if v.is_nil() => Ok(None),
41
+ Some(v) => {
42
+ let s: String = TryConvert::try_convert(v)?;
43
+ Ok(Some(s))
44
+ }
45
+ None => Ok(None),
46
+ }
47
+ }
48
+
49
+ /// Extract an optional bool value from a Ruby hash by symbol key.
50
+ fn hash_get_bool(hash: &RHash, key: &str) -> Result<Option<bool>, Error> {
51
+ let ruby = Ruby::get().expect("called from Ruby");
52
+ let sym = ruby.to_symbol(key);
53
+ let val: Option<Value> = hash.lookup(sym)?;
54
+ match val {
55
+ Some(v) if v.is_nil() => Ok(None),
56
+ Some(v) => {
57
+ let b: bool = TryConvert::try_convert(v)?;
58
+ Ok(Some(b))
59
+ }
60
+ None => Ok(None),
61
+ }
62
+ }
63
+
64
+ fn is_remote_url(path: &str) -> bool {
65
+ path.starts_with("libsql://") || path.starts_with("https://")
66
+ }
67
+
68
+ impl Database {
69
+ fn from_parts(rt: Runtime, db: libsql::Database) -> Self {
70
+ Self {
71
+ inner: RefCell::new(Some(DatabaseInner {
72
+ rt: Arc::new(rt),
73
+ db,
74
+ })),
75
+ }
76
+ }
77
+
78
+ fn with_inner<F, T>(&self, f: F) -> Result<T, Error>
79
+ where
80
+ F: FnOnce(&DatabaseInner) -> Result<T, Error>,
81
+ {
82
+ let inner = self.inner.borrow();
83
+ let inner = inner
84
+ .as_ref()
85
+ .ok_or_else(|| Error::new(error::closed_error(), "database is closed"))?;
86
+ f(inner)
87
+ }
88
+
89
+ fn open(args: &[Value]) -> Result<Self, Error> {
90
+ if args.is_empty() {
91
+ return Err(Error::new(
92
+ error::libsql_error(),
93
+ "wrong number of arguments (given 0, expected 1+)",
94
+ ));
95
+ }
96
+
97
+ let path: String = TryConvert::try_convert(args[0])?;
98
+
99
+ // Parse optional keyword arguments hash
100
+ let opts: Option<RHash> = if args.len() > 1 {
101
+ Some(TryConvert::try_convert(args[1])?)
102
+ } else {
103
+ None
104
+ };
105
+
106
+ let auth_token = if let Some(ref h) = opts {
107
+ hash_get_string(h, "auth_token")?
108
+ } else {
109
+ None
110
+ };
111
+ let local_path = if let Some(ref h) = opts {
112
+ hash_get_string(h, "local_path")?
113
+ } else {
114
+ None
115
+ };
116
+ let read_your_writes = if let Some(ref h) = opts {
117
+ hash_get_bool(h, "read_your_writes")?
118
+ } else {
119
+ None
120
+ };
121
+
122
+ if is_remote_url(&path) {
123
+ let token = auth_token.ok_or_else(|| {
124
+ Error::new(
125
+ error::libsql_error(),
126
+ "auth_token is required for remote connections",
127
+ )
128
+ })?;
129
+
130
+ if let Some(lp) = local_path {
131
+ Self::open_remote_replica(path, token, lp, read_your_writes)
132
+ } else {
133
+ Self::open_remote(path, token)
134
+ }
135
+ } else {
136
+ Self::open_local(path)
137
+ }
138
+ }
139
+
140
+ fn open_local(path: String) -> Result<Self, Error> {
141
+ // Validate parent directory exists for file paths
142
+ if path != ":memory:" {
143
+ let p = std::path::Path::new(&path);
144
+ if let Some(parent) = p.parent() {
145
+ if !parent.as_os_str().is_empty() && !parent.exists() {
146
+ return Err(Error::new(
147
+ error::libsql_error(),
148
+ format!("directory does not exist: {}", parent.display()),
149
+ ));
150
+ }
151
+ }
152
+ }
153
+
154
+ let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
155
+
156
+ let db = rt
157
+ .block_on(async {
158
+ if path == ":memory:" {
159
+ libsql::Builder::new_local(":memory:").build().await
160
+ } else {
161
+ libsql::Builder::new_local(&path).build().await
162
+ }
163
+ })
164
+ .map_err(error::to_error)?;
165
+
166
+ Ok(Self::from_parts(rt, db))
167
+ }
168
+
169
+ fn open_remote(url: String, auth_token: String) -> Result<Self, Error> {
170
+ let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
171
+
172
+ let db = rt
173
+ .block_on(async { libsql::Builder::new_remote(url, auth_token).build().await })
174
+ .map_err(error::to_error)?;
175
+
176
+ Ok(Self::from_parts(rt, db))
177
+ }
178
+
179
+ fn open_remote_replica(
180
+ url: String,
181
+ auth_token: String,
182
+ local_path: String,
183
+ read_your_writes: Option<bool>,
184
+ ) -> Result<Self, Error> {
185
+ let rt = Runtime::new().map_err(|e| Error::new(error::libsql_error(), e.to_string()))?;
186
+
187
+ let db = rt
188
+ .block_on(async {
189
+ let mut builder = libsql::Builder::new_remote_replica(local_path, url, auth_token);
190
+ if let Some(ryw) = read_your_writes {
191
+ builder = builder.read_your_writes(ryw);
192
+ }
193
+ builder.build().await
194
+ })
195
+ .map_err(error::to_error)?;
196
+
197
+ Ok(Self::from_parts(rt, db))
198
+ }
199
+
200
+ fn close(&self) -> Result<(), Error> {
201
+ // Drop both database and runtime to release file descriptors
202
+ let _ = self.inner.borrow_mut().take();
203
+ Ok(())
204
+ }
205
+
206
+ fn is_closed(&self) -> bool {
207
+ self.inner.borrow().is_none()
208
+ }
209
+
210
+ fn sync(&self) -> Result<(), Error> {
211
+ self.with_inner(|inner| {
212
+ // sync is a no-op for local/in-memory databases.
213
+ // Only embedded replicas actually perform synchronization.
214
+ let result = inner.rt.block_on(async { inner.db.sync().await });
215
+ match result {
216
+ Ok(_) => Ok(()),
217
+ Err(libsql::Error::SyncNotSupported(_)) => Ok(()),
218
+ Err(e) => Err(error::to_error(e)),
219
+ }
220
+ })
221
+ }
222
+
223
+ fn connect(&self) -> Result<Connection, Error> {
224
+ self.with_inner(|inner| {
225
+ let conn = inner
226
+ .rt
227
+ .block_on(async { inner.db.connect() })
228
+ .map_err(error::to_error)?;
229
+
230
+ Ok(Connection::new(Arc::clone(&inner.rt), conn))
231
+ })
232
+ }
233
+
234
+ fn discard(&self) -> Result<(), Error> {
235
+ // Drop both database and runtime to release all resources.
236
+ // Intended for use after fork (Puma/Unicorn) to discard
237
+ // inherited connections that are no longer valid.
238
+ let _ = self.inner.borrow_mut().take();
239
+ Ok(())
240
+ }
241
+ }