beagle-turso 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1330a594f1ac57da72b23e18b54eab7e725165754935b738c1f2154351592882
4
+ data.tar.gz: e19055c30925647fee54400f58a0038883a85e21fccc55f50020610a2a0275e2
5
+ SHA512:
6
+ metadata.gz: 110a98fa2e42812b091525c4f7b9a26f09f4bc19806c0310eaf48128c596df8061d142914e55fb77cd9bdcc7fe297fc0f6a8e55a627f4c2b8f11a63003c551b3
7
+ data.tar.gz: 50636fc7b516e13a161927f21ee147de26c34f18195c5f6b6380ceb6147071d689d09244d563dea368de66f6a82e32608145de9bf52904cad8d689ab95eb6e78
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # beagle-turso
2
+
3
+ A Ruby driver for [Turso](https://turso.tech)'s database engine, backed by a
4
+ native Rust extension (`beagle_turso_core`, via [Magnus](https://github.com/matsadler/magnus)/[rb_sys](https://github.com/oxidize-rb/rb-sys)).
5
+
6
+ A `Database` can be opened two ways:
7
+
8
+ - **Local-only** — in-memory or an on-disk file, no network involved.
9
+ - **Synced** — a local file kept in sync with a remote Turso database via
10
+ `push`/`pull`.
11
+
12
+ ## Install
13
+
14
+ Add to your `Gemfile`:
15
+
16
+ ```ruby
17
+ gem "beagle-turso"
18
+ ```
19
+
20
+ or install directly:
21
+
22
+ ```sh
23
+ gem install beagle-turso
24
+ ```
25
+
26
+ Installing builds the native extension via `rb_sys`/`rake-compiler`, so a
27
+ Rust toolchain is required unless a precompiled binary gem is available for
28
+ your platform.
29
+
30
+ ## Local example
31
+
32
+ ```ruby
33
+ require "beagle/turso"
34
+
35
+ db = Beagle::Turso::Database.open_local(":memory:")
36
+ conn = db.connect
37
+
38
+ conn.execute("CREATE TABLE users (id INTEGER, name TEXT)", [])
39
+ conn.execute("INSERT INTO users (id, name) VALUES (?, ?)", [1, "Ada"])
40
+
41
+ rows = conn.query("SELECT id, name FROM users", [])
42
+ # => [[1, "Ada"]]
43
+ ```
44
+
45
+ `execute` returns the number of affected rows (an `Integer`); `query`
46
+ returns an `Array` of row `Array`s. Bind parameters may be `nil`, `Integer`,
47
+ `Float`, `String` (bound as `TEXT`), or a binary `String` — one with
48
+ `ASCII-8BIT`/`BINARY` encoding — bound as `BLOB`.
49
+
50
+ ## Synced example
51
+
52
+ ```ruby
53
+ require "beagle/turso"
54
+
55
+ db = Beagle::Turso::Database.open(
56
+ local_path: "/path/to/local.db",
57
+ remote_url: ENV.fetch("TURSO_DATABASE_URL"),
58
+ auth_token: ENV.fetch("TURSO_AUTH_TOKEN")
59
+ )
60
+
61
+ conn = db.connect
62
+ conn.execute("INSERT INTO users (id, name) VALUES (?, ?)", [2, "Grace"])
63
+
64
+ db.push # propagate local writes to the remote
65
+ db.pull # pull remote changes down to the local file (returns true/false)
66
+ ```
67
+
68
+ `Database.open` also accepts `bootstrap_if_empty:` (defaults to `true`),
69
+ which pulls all remote data down on first open if the local file is new/empty.
70
+ Calling `push`/`pull` on a database opened via `open_local` (or via `open`
71
+ without both `remote_url:` and `auth_token:`) raises `RuntimeError`.
72
+
73
+ ### Durability note: writes commit on-sync, not synchronously
74
+
75
+ Writes to a synced database commit to the **local** file immediately, as
76
+ part of `execute` — they do not wait on the network. They are only
77
+ propagated to the remote **on sync**, i.e. whenever `push` is explicitly
78
+ called; `push` is not called automatically after every write. Until a
79
+ `push` succeeds, a write that's durable locally is not yet visible to other
80
+ replicas of the same remote database.
81
+
82
+ ## Credential safety
83
+
84
+ `auth_token` is never included in `inspect` output or in error messages —
85
+ confirmed by this gem's `no_secret_logging_spec.rb`.
86
+
87
+ ## License
88
+
89
+ [MIT](https://opensource.org/licenses/MIT).
@@ -0,0 +1,12 @@
1
+ [package]
2
+ name = "beagle_turso"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ magnus = "0.8"
12
+ beagle_turso_core = "0.1.0"
@@ -0,0 +1,4 @@
1
+ require "mkmf"
2
+ require "rb_sys/mkmf"
3
+
4
+ create_rust_makefile("beagle_turso/beagle_turso")
@@ -0,0 +1,264 @@
1
+ // Wraps beagle_turso_core's Database and Connection as persistent Ruby
2
+ // objects, exposing a synchronous open -> connect -> execute -> query API.
3
+ // Lifted from the proven spike at spikes/magnus-wrap/src/lib.rs (Plan 2's
4
+ // binding proof).
5
+ //
6
+ // One deliberate deviation from the spike: each wrapped fn/method takes
7
+ // `ruby: &Ruby` as its leading parameter (magnus's `function!`/`method!`
8
+ // macros inject it automatically — see the magnus 0.8 README's "Error
9
+ // Handling" example) instead of calling the fallible `Ruby::get()` inside
10
+ // the body. That sidesteps the "Ruby unavailable" fallback branch, which
11
+ // has no live `&Ruby` to call the non-deprecated `Ruby::exception_*`
12
+ // accessors on and so would otherwise need the deprecated
13
+ // `magnus::exception::standard_error()` free function. Net effect is
14
+ // identical; the code is simpler and compiles with zero deprecation
15
+ // warnings.
16
+
17
+ use beagle_turso_core::{Connection, Database, OpenOptions, Value};
18
+ use magnus::{
19
+ encoding::EncodingCapable, function, method, prelude::*, Error, Float, Integer, IntoValue,
20
+ RArray, RString, Ruby,
21
+ };
22
+
23
+ #[magnus::wrap(class = "Beagle::Turso::Database", free_immediately)]
24
+ struct RbDatabase {
25
+ inner: Database,
26
+ }
27
+
28
+ #[magnus::wrap(class = "Beagle::Turso::Connection", free_immediately)]
29
+ struct RbConnection {
30
+ inner: Connection,
31
+ }
32
+
33
+ fn rt_err(ruby: &Ruby, msg: String) -> Error {
34
+ Error::new(ruby.exception_runtime_error(), msg)
35
+ }
36
+
37
+ fn ruby_array_to_params(ruby: &Ruby, arr: RArray) -> Result<Vec<Value>, Error> {
38
+ // Safe: `from_value` below is a raw type check on the Ruby object (exact
39
+ // class match — no `to_int`/`to_f`/`to_str` coercion method dispatch), so
40
+ // for the sanctioned nil/Integer/Float/String inputs no arbitrary Ruby
41
+ // code (which could GC/mutate the array) runs during this loop; an
42
+ // unsupported type falls straight to the `else` arm without invoking any
43
+ // Ruby method at all. The binary/text split below on an `RString` is
44
+ // likewise native: `EncodingCapable::enc_get` is a direct
45
+ // `rb_enc_get_index` C call (no method dispatch). For the binary branch
46
+ // `as_slice` is copied into an owned `Vec` immediately, before any
47
+ // further Ruby call in this loop iteration, so the borrow never outlives
48
+ // a point where the backing string could be mutated/GC'd out from under
49
+ // it.
50
+ let items = unsafe { arr.as_slice() };
51
+ let ascii_8bit = ruby.ascii8bit_encindex();
52
+ let mut out = Vec::with_capacity(items.len());
53
+ for &item in items {
54
+ let v = if item.is_nil() {
55
+ Value::Null
56
+ } else if let Some(i) = Integer::from_value(item) {
57
+ Value::Integer(i.to_i64()?)
58
+ } else if let Some(f) = Float::from_value(item) {
59
+ Value::Real(f.to_f64())
60
+ } else if let Some(s) = RString::from_value(item) {
61
+ if s.enc_get() == ascii_8bit {
62
+ Value::Blob(unsafe { s.as_slice() }.to_vec())
63
+ } else {
64
+ Value::Text(s.to_string()?)
65
+ }
66
+ } else {
67
+ return Err(Error::new(
68
+ ruby.exception_type_error(),
69
+ "unsupported bind parameter type",
70
+ ));
71
+ };
72
+ out.push(v);
73
+ }
74
+ Ok(out)
75
+ }
76
+
77
+ fn value_to_ruby(ruby: &Ruby, v: &Value) -> magnus::Value {
78
+ match v {
79
+ Value::Null => ruby.qnil().as_value(),
80
+ Value::Integer(i) => (*i).into_value_with(ruby),
81
+ Value::Real(f) => (*f).into_value_with(ruby),
82
+ Value::Text(s) => s.clone().into_value_with(ruby),
83
+ Value::Blob(b) => ruby.str_from_slice(b).as_value(),
84
+ }
85
+ }
86
+
87
+ impl RbDatabase {
88
+ fn open_local(ruby: &Ruby, path: String) -> Result<RbDatabase, Error> {
89
+ let opts = OpenOptions {
90
+ local_path: path,
91
+ remote_url: None,
92
+ auth_token: None,
93
+ bootstrap_if_empty: true,
94
+ };
95
+ let db = Database::open(opts).map_err(|e| rt_err(ruby, e.to_string()))?;
96
+ Ok(RbDatabase { inner: db })
97
+ }
98
+
99
+ // Positional primitive backing the keyword-arg `Database.open` defined in
100
+ // Ruby (see lib/beagle/turso.rb). Kept positional so magnus can
101
+ // TryConvert `nil`/`String`/bool natively for each argument, instead of
102
+ // parsing an RHash of keyword args by hand in Rust.
103
+ fn open(
104
+ ruby: &Ruby,
105
+ local_path: String,
106
+ remote_url: Option<String>,
107
+ auth_token: Option<String>,
108
+ bootstrap_if_empty: bool,
109
+ ) -> Result<RbDatabase, Error> {
110
+ let opts = OpenOptions {
111
+ local_path,
112
+ remote_url,
113
+ auth_token,
114
+ bootstrap_if_empty,
115
+ };
116
+ let db = Database::open(opts).map_err(|e| rt_err(ruby, e.to_string()))?;
117
+ Ok(RbDatabase { inner: db })
118
+ }
119
+
120
+ fn connect(ruby: &Ruby, rb_self: &Self) -> Result<RbConnection, Error> {
121
+ let c = rb_self
122
+ .inner
123
+ .connect()
124
+ .map_err(|e| rt_err(ruby, e.to_string()))?;
125
+ Ok(RbConnection { inner: c })
126
+ }
127
+
128
+ fn push(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
129
+ rb_self
130
+ .inner
131
+ .push()
132
+ .map_err(|e| rt_err(ruby, e.to_string()))
133
+ }
134
+
135
+ fn pull(ruby: &Ruby, rb_self: &Self) -> Result<bool, Error> {
136
+ rb_self
137
+ .inner
138
+ .pull()
139
+ .map_err(|e| rt_err(ruby, e.to_string()))
140
+ }
141
+
142
+ // Release the underlying engine handle and, for a synced database, end its
143
+ // remote sync session, instead of waiting for GC. Idempotent.
144
+ fn close(_ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
145
+ rb_self.inner.close();
146
+ Ok(())
147
+ }
148
+
149
+ fn is_closed(_ruby: &Ruby, rb_self: &Self) -> bool {
150
+ rb_self.inner.is_closed()
151
+ }
152
+ }
153
+
154
+ impl RbConnection {
155
+ fn execute(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<u64, Error> {
156
+ let p = ruby_array_to_params(ruby, params)?;
157
+ rb_self
158
+ .inner
159
+ .execute(&sql, &p)
160
+ .map_err(|e| rt_err(ruby, e.to_string()))
161
+ }
162
+
163
+ fn query(ruby: &Ruby, rb_self: &Self, sql: String, params: RArray) -> Result<RArray, Error> {
164
+ let p = ruby_array_to_params(ruby, params)?;
165
+ let rows = rb_self
166
+ .inner
167
+ .query(&sql, &p)
168
+ .map_err(|e| rt_err(ruby, e.to_string()))?;
169
+ let out = ruby.ary_new();
170
+ for row in rows {
171
+ let rb_row = ruby.ary_new();
172
+ for val in &row.values {
173
+ rb_row.push(value_to_ruby(ruby, val))?;
174
+ }
175
+ out.push(rb_row)?;
176
+ }
177
+ Ok(out)
178
+ }
179
+
180
+ // Like `query`, but also returns column names — needed by callers (e.g.
181
+ // an ActiveRecord adapter) that must know column identity, not just
182
+ // positional values. Returns a 2-element `[columns, rows]` array;
183
+ // `rows` is built with the same loop `query` uses.
184
+ fn query_result(
185
+ ruby: &Ruby,
186
+ rb_self: &Self,
187
+ sql: String,
188
+ params: RArray,
189
+ ) -> Result<RArray, Error> {
190
+ let p = ruby_array_to_params(ruby, params)?;
191
+ let result = rb_self
192
+ .inner
193
+ .query_result(&sql, &p)
194
+ .map_err(|e| rt_err(ruby, e.to_string()))?;
195
+ let columns = ruby.ary_new();
196
+ for name in &result.columns {
197
+ columns.push(name.clone().into_value_with(ruby))?;
198
+ }
199
+ let rows = ruby.ary_new();
200
+ for row in result.rows {
201
+ let rb_row = ruby.ary_new();
202
+ for val in &row.values {
203
+ rb_row.push(value_to_ruby(ruby, val))?;
204
+ }
205
+ rows.push(rb_row)?;
206
+ }
207
+ let out = ruby.ary_new();
208
+ out.push(columns)?;
209
+ out.push(rows)?;
210
+ Ok(out)
211
+ }
212
+
213
+ fn last_insert_rowid(ruby: &Ruby, rb_self: &Self) -> Result<i64, Error> {
214
+ rb_self
215
+ .inner
216
+ .last_insert_rowid()
217
+ .map_err(|e| rt_err(ruby, e.to_string()))
218
+ }
219
+
220
+ fn execute_batch(ruby: &Ruby, rb_self: &Self, sql: String) -> Result<(), Error> {
221
+ rb_self
222
+ .inner
223
+ .execute_batch(&sql)
224
+ .map_err(|e| rt_err(ruby, e.to_string()))
225
+ }
226
+
227
+ // Release the underlying connection handle instead of waiting for GC.
228
+ // Idempotent.
229
+ fn close(_ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
230
+ rb_self.inner.close();
231
+ Ok(())
232
+ }
233
+
234
+ fn is_closed(_ruby: &Ruby, rb_self: &Self) -> bool {
235
+ rb_self.inner.is_closed()
236
+ }
237
+ }
238
+
239
+ #[magnus::init]
240
+ fn init(ruby: &Ruby) -> Result<(), Error> {
241
+ let turso = ruby.define_module("Beagle")?.define_module("Turso")?;
242
+ let db = turso.define_class("Database", ruby.class_object())?;
243
+ db.define_singleton_method("open_local", function!(RbDatabase::open_local, 1))?;
244
+ // Positional primitive; the public keyword-arg `Database.open` wrapper
245
+ // lives in Ruby (lib/beagle/turso.rb) and delegates here.
246
+ db.define_singleton_method("_open", function!(RbDatabase::open, 4))?;
247
+ db.define_method("connect", method!(RbDatabase::connect, 0))?;
248
+ db.define_method("push", method!(RbDatabase::push, 0))?;
249
+ db.define_method("pull", method!(RbDatabase::pull, 0))?;
250
+ db.define_method("close", method!(RbDatabase::close, 0))?;
251
+ db.define_method("closed?", method!(RbDatabase::is_closed, 0))?;
252
+ let conn = turso.define_class("Connection", ruby.class_object())?;
253
+ conn.define_method("execute", method!(RbConnection::execute, 2))?;
254
+ conn.define_method("query", method!(RbConnection::query, 2))?;
255
+ conn.define_method("query_result", method!(RbConnection::query_result, 2))?;
256
+ conn.define_method(
257
+ "last_insert_rowid",
258
+ method!(RbConnection::last_insert_rowid, 0),
259
+ )?;
260
+ conn.define_method("execute_batch", method!(RbConnection::execute_batch, 1))?;
261
+ conn.define_method("close", method!(RbConnection::close, 0))?;
262
+ conn.define_method("closed?", method!(RbConnection::is_closed, 0))?;
263
+ Ok(())
264
+ }
@@ -0,0 +1,5 @@
1
+ module Beagle
2
+ module Turso
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "turso/version"
2
+ require "beagle_turso/beagle_turso" # native extension
3
+
4
+ module Beagle
5
+ module Turso
6
+ class Database
7
+ # Opens a local-only database, or one synced with a remote Turso
8
+ # database when both `remote_url` and `auth_token` are given (see
9
+ # `#push`/`#pull`). Delegates to the native `_open` primitive, which
10
+ # takes its arguments positionally.
11
+ def self.open(local_path:, remote_url: nil, auth_token: nil, bootstrap_if_empty: true)
12
+ _open(local_path, remote_url, auth_token, bootstrap_if_empty)
13
+ end
14
+
15
+ private_class_method :_open
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: beagle-turso
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - BeagleSoftwareUK
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rb_sys
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.9'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.9'
27
+ description: |
28
+ A Ruby driver for Turso's database engine, backed by a native Rust
29
+ extension (beagle_turso_core, via Magnus/rb-sys). Opens a local-only
30
+ database (in-memory or on-disk) or one kept in sync with a remote Turso
31
+ database via explicit push/pull. Writes are durable to the local file
32
+ immediately as they happen; push is what propagates them to the remote
33
+ on sync, not synchronously with every write.
34
+ email:
35
+ executables: []
36
+ extensions:
37
+ - ext/beagle_turso/extconf.rb
38
+ extra_rdoc_files: []
39
+ files:
40
+ - README.md
41
+ - ext/beagle_turso/Cargo.toml
42
+ - ext/beagle_turso/extconf.rb
43
+ - ext/beagle_turso/src/lib.rs
44
+ - lib/beagle/turso.rb
45
+ - lib/beagle/turso/version.rb
46
+ homepage: https://github.com/BeagleSoftwareUK/beagle-turso
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ source_code_uri: https://github.com/BeagleSoftwareUK/beagle-turso
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.3'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.5.22
67
+ signing_key:
68
+ specification_version: 4
69
+ summary: Ruby driver for Turso's new engine, backed by beagle_turso_core (Rust).
70
+ test_files: []