activerecord-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: 63f87bcbfe56cbc279209bb29456d62bd757f1d4ea32d69ffc8c922891121c4e
4
+ data.tar.gz: 94028b900a3aa33ac4bbfb5e975101dad3289016f601021085aa15445fe22d6a
5
+ SHA512:
6
+ metadata.gz: a407e3b271141a740dcc6c5996165ce17dd3ab4aedb0605a769ef4586e65af19825001958e42c60a4e544e5af3c4528af013312936f214dd00d36b9c9ee5b12d
7
+ data.tar.gz: d22efba814f090b6a114aa72e29e3e882c3acb87d542279f72eab16bf2ec7f48a6c381c38b4cb9d3e8a0b87bd3d79c0d187cefe0de9b604c92b1c1b55dba1624
data/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # activerecord-beagle-turso
2
+
3
+ An ActiveRecord connection adapter for [Turso](https://turso.tech)'s libsql
4
+ engine, backed by the [`beagle-turso`](../beagle-turso) driver. It subclasses
5
+ Rails' stock `SQLite3Adapter` and reroutes only the raw-execution seam onto
6
+ `beagle-turso`, so SQL generation, quoting, schema introspection, and
7
+ transaction handling are all inherited unchanged — you get a normal
8
+ ActiveRecord experience, running against a local, in-memory, or
9
+ remote-synced Turso database.
10
+
11
+ ## Install
12
+
13
+ Add both gems to your `Gemfile` (`activerecord-beagle-turso` depends on
14
+ `beagle-turso`, but Bundler needs it listed explicitly if you're installing
15
+ from a path/git source rather than RubyGems):
16
+
17
+ ```ruby
18
+ gem "activerecord-beagle-turso"
19
+ gem "beagle-turso"
20
+ ```
21
+
22
+ or install directly:
23
+
24
+ ```sh
25
+ gem install activerecord-beagle-turso
26
+ ```
27
+
28
+ `beagle-turso` has a native (Rust) extension, so installing it requires a
29
+ Rust toolchain unless a precompiled binary gem is available for your
30
+ platform — see [`beagle-turso`'s README](../beagle-turso/README.md#install)
31
+ for details. **There is currently no precompiled/cross-compiled gem release
32
+ job for either gem** — installing means building the extension locally
33
+ (or, as in this repo, from a path dependency built once already).
34
+
35
+ ## `config/database.yml`
36
+
37
+ Set `adapter: beagle_turso`. A **local** connection (no sync) needs only
38
+ `database:`:
39
+
40
+ ```yaml
41
+ development:
42
+ adapter: beagle_turso
43
+ database: storage/development.sqlite3
44
+ ```
45
+
46
+ A **synced** connection additionally sets `remote_url:` and `auth_token:` —
47
+ both are required together to enable sync (see
48
+ [`Beagle::Turso::Database.open`](../beagle-turso/README.md#synced-example)):
49
+
50
+ ```yaml
51
+ production:
52
+ adapter: beagle_turso
53
+ database: storage/production.sqlite3
54
+ remote_url: <%= Rails.application.credentials.dig(:turso, :prod_url) %>
55
+ auth_token: <%= Rails.application.credentials.dig(:turso, :prod_token) %>
56
+ # Informational only (see "Recurring sync" below) -- not read by this
57
+ # adapter or by SyncManager. Keep it in sync with recurring.yml by hand.
58
+ sync_interval: 30 # seconds
59
+ ```
60
+
61
+ Never log `remote_url`/`auth_token` — Rails masks `password`-ish keys in
62
+ some diagnostics, but these are custom `database.yml` keys, so that masking
63
+ does not cover them automatically. Source them from encrypted credentials or
64
+ `ENV`, as above, and avoid logging/inspecting the raw connection config.
65
+
66
+ An optional `bootstrap_if_empty:` (default `true`) controls whether a brand
67
+ new/empty local file is populated from the remote on first open — see
68
+ `Beagle::Turso::Database.open`.
69
+
70
+ ### Durability note: writes commit on-sync, not synchronously
71
+
72
+ Writes go to the **local** file immediately, as part of the normal
73
+ ActiveRecord write path (`INSERT`/`UPDATE`/`DELETE` all commit locally,
74
+ synchronously, like any SQLite-backed adapter). They are only propagated to
75
+ the remote Turso database **on sync** — i.e. whenever something calls
76
+ `push!` (see below) — not automatically after every write or transaction
77
+ commit. Until a sync succeeds, a write that is fully durable on this
78
+ replica's local disk is not yet visible to any other replica of the same
79
+ remote database. Plan your sync cadence (and any read-after-write
80
+ expectations across replicas) around that gap.
81
+
82
+ ## Sync: `Beagle::Turso::SyncManager` + Solid Queue recurring job
83
+
84
+ `Beagle::Turso::SyncManager` drives push/pull from the ActiveRecord layer,
85
+ given a connection using `adapter: beagle_turso`:
86
+
87
+ ```ruby
88
+ Beagle::Turso::SyncManager.push!(ActiveRecord::Base.connection) # local writes -> remote
89
+ Beagle::Turso::SyncManager.pull!(ActiveRecord::Base.connection) # remote writes -> local
90
+ Beagle::Turso::SyncManager.sync!(ActiveRecord::Base.connection) # push!, then pull!
91
+ ```
92
+
93
+ All three default their `connection` argument to `ActiveRecord::Base.connection`,
94
+ so `Beagle::Turso::SyncManager.sync!` alone is the usual call. Calling any of
95
+ them against a **local-only** connection (no `remote_url:`/`auth_token:` in
96
+ `database.yml`) raises `Beagle::Turso::SyncManager::NotSyncedError` — a
97
+ distinct, rescuable type — rather than the driver's bare `RuntimeError`.
98
+
99
+ To sync on a timer, wire
100
+ `ActiveRecord::ConnectionAdapters::BeagleTurso::SyncJob` (a thin delegator to
101
+ `SyncManager.sync!`) into [Solid Queue's recurring
102
+ jobs](https://github.com/rails/solid_queue?tab=readme-ov-file#recurring-tasks).
103
+ This gem does not generate or load `config/recurring.yml` itself — copy the
104
+ relevant block from
105
+ [`config/recurring.yml.example`](config/recurring.yml.example) into your
106
+ host app's own `config/recurring.yml`, for whichever environment(s) actually
107
+ use a synced connection:
108
+
109
+ ```yaml
110
+ production:
111
+ beagle_turso_sync:
112
+ class: ActiveRecord::ConnectionAdapters::BeagleTurso::SyncJob
113
+ queue: default
114
+ schedule: every 30 seconds # match database.yml's sync_interval: for this env
115
+ ```
116
+
117
+ `SyncJob` subclasses `ActiveJob::Base` when ActiveJob is loaded (the normal
118
+ case in a Rails app running Solid Queue) and falls back to a plain class
119
+ with the same `#perform` otherwise, so requiring this gem never raises a
120
+ `LoadError` on its own in a non-Rails context.
121
+
122
+ A recurring `SyncJob` against a local-only connection will fail every run
123
+ with `NotSyncedError` — only add the recurring entry for environments whose
124
+ `database.yml` connection actually carries `remote_url:`/`auth_token:`.
125
+
126
+ ## Test coverage
127
+
128
+ This gem's own spec suite (`spec/`, run via `bundle exec rspec`) is 35
129
+ examples covering, against the real `beagle-turso` driver (nothing mocked):
130
+ CRUD through a model, foreign-key enforcement, data-modifying CTEs,
131
+ transactions (commit/rollback/nested `requires_new`), DDL
132
+ (`add_column`/`remove_column`/`change_column`/`add_index`), every AR column
133
+ type round-tripping through a model, `pluck`/`where`/`update_all`/`delete_all`,
134
+ and (with real Turso credentials — otherwise a clean `PENDING` skip, no
135
+ silent pass) a genuine push/pull sync round trip between two replicas.
136
+
137
+ ### Real-Rails-boot integration test
138
+
139
+ `spec/dummy_app_integration_spec.rb` boots `test/dummy` — a small but
140
+ **genuine** `Rails::Application` (not a mock or a stand-in) — through Rails'
141
+ own `config/environment.rb` → `Rails.application.initialize!` sequence, the
142
+ same path a real app's `bin/rails server`/`bin/rails console`/`bin/rails db:migrate`
143
+ takes. It proves the adapter survives that boot rather than only the
144
+ hand-called `ActiveRecord::Base.establish_connection(adapter: "beagle_turso", ...)`
145
+ the rest of the suite uses:
146
+
147
+ - `adapter: beagle_turso` is resolved from a real `config/database.yml` by
148
+ `ActiveRecord::Railtie`'s own `active_record.initialize_database`
149
+ initializer, not passed as a literal hash.
150
+ - A real `ActiveRecord::Migration` file is run via `ActiveRecord::MigrationContext`
151
+ (not `ActiveRecord::Schema.define`, which the rest of the suite uses) —
152
+ this exercises `schema_migrations`/`ar_internal_metadata` bookkeeping,
153
+ which no other spec in this gem touches.
154
+ - The model under test (`DummyWidget`) is never `require`d by hand — it's
155
+ resolved through Rails' normal Zeitwerk `app/models` autoloading.
156
+
157
+ `test/dummy` intentionally boots only the frameworks
158
+ `ActiveRecord::Railtie` itself pulls in (it hard-requires
159
+ `action_controller/railtie`, and transitively `action_view/railtie`, for
160
+ long-standing middleware-configuration reasons — see the comment at the top
161
+ of Rails' `active_record/railtie.rb`) — no Active Job, Action Mailer, Active
162
+ Storage, Action Cable, etc. See `test/dummy/config/application.rb` for the
163
+ exact boot list.
164
+
165
+ ### What's deferred: ActiveRecord's own internal adapter test suite
166
+
167
+ ActiveRecord ships an extensive internal adapter conformance suite
168
+ (`activerecord/test/cases/*_test.rb` in the [Rails source
169
+ tree](https://github.com/rails/rails/tree/v8.1.3.1/activerecord/test/cases))
170
+ that official adapters (`mysql2`, `pg`, `trilogy`) are exercised against.
171
+ **This gem does not vendor or run that suite, and that is a deliberate,
172
+ documented decision, not a silent gap:**
173
+
174
+ - That suite is not shipped in the released `activerecord` gem — only in
175
+ the Rails monorepo's source tree. It is not designed for consumption by
176
+ external adapter gems.
177
+ - We confirmed this concretely: even a single, relatively adapter-agnostic
178
+ file from it (`test/cases/adapter_test.rb`) `require`s `cases/helper`,
179
+ `support/connection_helper`, and several `models/*` fixture classes,
180
+ which in turn depend on ActiveRecord's internal `ARTest` test harness and
181
+ its ~1,500-line `test/schema/schema.rb` (dozens of interlocking fixture
182
+ tables — authors, books, posts, comments, and more) plus a `test/config.yml`
183
+ whose format/location has itself shifted between Rails versions. None of
184
+ this is a stable, public interface; vendoring even one file means
185
+ vendoring most of that infrastructure and keeping it hand-synced with
186
+ whatever Rails version this gem targets.
187
+ - This is precisely why official third-party adapters handle it by
188
+ checking out the **entire** Rails source at a pinned commit/tag (often as
189
+ a CI-only step, sometimes a git submodule) and running AR's suite
190
+ in-tree against their adapter — a substantial, ongoing-maintenance
191
+ commitment, not something addable as a "focused subset" inside a single
192
+ gem release.
193
+ - What that suite covers beyond this gem's own 35 examples: exhaustive
194
+ per-type edge cases (encoding, precision/scale boundaries, reserved-word
195
+ quoting across dozens of identifiers), schema-dumping round-trips
196
+ (`db/schema.rb` load-and-compare), the full fixture-based association
197
+ graph, and adapter-independent behavior that has nothing to do with the
198
+ raw-execution seam this adapter actually changes. None of that is
199
+ exercised here.
200
+
201
+ If this adapter needs that level of assurance in the future (e.g. before a
202
+ 1.0 release, or before proposing it for inclusion upstream), the right next
203
+ step is a separately-scoped effort to vendor the full Rails source at a
204
+ pinned tag and run its adapter suite in CI — not a partial port bolted onto
205
+ this gem.
206
+
207
+ ## Limitations (v1)
208
+
209
+ 1. **Uncast raw/computed SELECT values.** Reads go through `ActiveRecord::Result`
210
+ without result column types, so ordinary model attributes and `pluck(:column)`
211
+ cast correctly, but a raw/computed SELECT expression with no backing attribute
212
+ (e.g. `select_all("SELECT some_datetime_expr ...")`, `pluck(Arel.sql("..."))`)
213
+ may come back uncast (string / 0/1 instead of Time / boolean). Cast such values
214
+ yourself.
215
+
216
+ 2. **Data-modifying CTEs and read-replica write-guards.** A hand-written
217
+ `WITH ... UPDATE/INSERT/DELETE` executes correctly, but is classified as a read
218
+ by ActiveRecord's write-guard layer — so under `connected_to(role: :reading)` it
219
+ won't trip the read-only guard, and it won't mark the transaction dirty. Use
220
+ plain `UPDATE`/`DELETE` (or `Model.update_all`/`delete_all`) if you rely on
221
+ read/write splitting.
222
+
223
+ 3. **Sync holds the GVL.** `SyncManager.push!`/`pull!` block Ruby's GVL for the
224
+ full network round-trip. Under `SOLID_QUEUE_IN_PUMA=true` (jobs in the Puma
225
+ process), a recurring `SyncJob` stalls all Puma threads for the duration of
226
+ each sync — pick a sync cadence accordingly, or run Solid Queue in a separate
227
+ process.
228
+
229
+ ## License
230
+
231
+ [MIT](https://opensource.org/licenses/MIT).
@@ -0,0 +1,22 @@
1
+ # Example Solid Queue recurring-job entry for ActiveRecord::ConnectionAdapters::BeagleTurso::SyncJob.
2
+ #
3
+ # This is NOT loaded by this gem -- copy the relevant environment block(s)
4
+ # into your host Rails app's own config/recurring.yml (Solid Queue reads
5
+ # that file; see the solid_queue README for the full recurring.yml format).
6
+ # Only add this for environments whose database.yml connection is actually
7
+ # synced (has both remote_url: and auth_token: -- see
8
+ # lib/active_record/connection_adapters/beagle_turso/sync_manager.rb). A
9
+ # recurring SyncJob against a local-only connection will fail every run with
10
+ # Beagle::Turso::SyncManager::NotSyncedError.
11
+ #
12
+ # Keep the `schedule:` cadence here in sync (by hand) with whatever
13
+ # `sync_interval:` you documented in database.yml for the same environment --
14
+ # Solid Queue does not read database.yml, so there is no single source of
15
+ # truth to enforce this automatically.
16
+
17
+ production:
18
+ beagle_turso_sync:
19
+ class: ActiveRecord::ConnectionAdapters::BeagleTurso::SyncJob
20
+ queue: default
21
+ # Match database.yml's production `sync_interval:` for this connection.
22
+ schedule: every 30 seconds
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/connection_adapters/beagle_turso/sync_manager"
4
+
5
+ module ActiveRecord
6
+ module ConnectionAdapters
7
+ module BeagleTurso
8
+ # Thin Solid Queue recurring job: on each run, it just delegates to
9
+ # Beagle::Turso::SyncManager.sync! (push local writes, then pull remote
10
+ # ones) against the app's default AR connection. No retry/backoff logic
11
+ # here -- Solid Queue's own job-failure handling covers that, and
12
+ # SyncManager itself carries no state between runs.
13
+ #
14
+ # Wire it up via config/recurring.yml (see
15
+ # config/recurring.yml.example alongside this file), scheduled at
16
+ # whatever cadence database.yml's +sync_interval:+ documents for the
17
+ # target environment. This gem does not read recurring.yml or generate
18
+ # it -- add the entry to the host app's own file.
19
+ #
20
+ # This gem does not declare a hard dependency on `activejob` (a Rails
21
+ # app using Solid Queue already has it loaded by the time this file is
22
+ # required). When ActiveJob is present, SyncJob subclasses
23
+ # ActiveJob::Base as Solid Queue expects; otherwise it falls back to a
24
+ # plain class with the same #perform, so requiring this file never
25
+ # raises a LoadError on its own.
26
+ sync_job_superclass = defined?(::ActiveJob::Base) ? ::ActiveJob::Base : Object
27
+
28
+ class SyncJob < sync_job_superclass
29
+ def perform
30
+ ::Beagle::Turso::SyncManager.sync!
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beagle
4
+ module Turso
5
+ # Drives a synced beagle-turso database's `push`/`pull` from the
6
+ # ActiveRecord layer, given an AR connection using +adapter: beagle_turso+
7
+ # (see BeagleTursoAdapter). It is a thin delegator: all the real sync work
8
+ # -- WAL-frame push/pull against the remote Turso database -- happens in
9
+ # Beagle::Turso::Database#push/#pull (native, via beagle_turso_core). This
10
+ # module only reaches +connection.raw_connection+ (the adapter's +Client+,
11
+ # see BeagleTursoAdapter::Client) and calls through.
12
+ #
13
+ # Intended as the callback a Solid Queue recurring job invokes on a timer
14
+ # (see ActiveRecord::ConnectionAdapters::BeagleTurso::SyncJob) -- not a
15
+ # sync engine in its own right. It does not schedule itself, retry, queue
16
+ # writes, or track sync state; scheduling is Solid Queue's job (see
17
+ # config/recurring.yml.example alongside this file).
18
+ #
19
+ # == database.yml keys this depends on
20
+ #
21
+ # A connection is "synced" -- i.e. what +push!+/+pull!+ operate on --
22
+ # only when its +database.yml+ entry carries BOTH of:
23
+ #
24
+ # remote_url: # the Turso database's libsql:// URL
25
+ # auth_token: # its auth token
26
+ #
27
+ # (See BeagleTursoAdapter.new_client.) A third key, +sync_interval:+, is
28
+ # NOT read by this adapter or by SyncManager -- it exists purely as a
29
+ # documented convention for how often a host app's config/recurring.yml
30
+ # should invoke SyncJob; keep the two in sync by hand (or via ERB) since
31
+ # Solid Queue's recurring.yml is not sourced from database.yml. Example:
32
+ #
33
+ # # config/database.yml
34
+ # production:
35
+ # adapter: beagle_turso
36
+ # database: storage/production.sqlite3
37
+ # remote_url: <%= Rails.application.credentials.dig(:turso, :prod_url) %>
38
+ # auth_token: <%= Rails.application.credentials.dig(:turso, :prod_token) %>
39
+ # sync_interval: 30 # seconds -- informational; wire it into recurring.yml yourself
40
+ #
41
+ # Never log +remote_url+/+auth_token+ -- Rails masks +password+-ish keys
42
+ # in some diagnostics, but these are custom keys, so it doesn't cover them
43
+ # automatically. Source them from encrypted credentials or ENV (as
44
+ # BeagleTursoAdapter's callers already do) and don't pass them through
45
+ # anything that inspects/logs config (e.g. don't log +connection_config+).
46
+ module SyncManager
47
+ # Raised by +push!+/+pull!+/+sync!+ when the given connection is
48
+ # local-only (its database.yml entry has no +remote_url+/+auth_token+,
49
+ # so BeagleTursoAdapter opened it via +Database.open_local+). The
50
+ # underlying Client#push/#pull already raise a bare RuntimeError for
51
+ # this (see Beagle::Turso::Database#push/#pull); SyncManager re-raises
52
+ # it as this distinct, documented type so callers can rescue it
53
+ # specifically instead of matching on the driver's message text. A
54
+ # genuine sync failure against a real remote (network error, auth
55
+ # failure, etc.) is NOT reclassified -- it propagates as whatever error
56
+ # the driver raised, unchanged.
57
+ class NotSyncedError < StandardError; end
58
+
59
+ # Matches only the driver's local-only wording (see
60
+ # beagle_turso_core's `Error::Sync` in `Database::push`/`Database::pull`)
61
+ # so a real sync failure -- which also surfaces as a RuntimeError, just
62
+ # with different text -- is never misreported as "not synced".
63
+ LOCAL_ONLY_MESSAGE_REGEX = /on a local-only database/i
64
+ private_constant :LOCAL_ONLY_MESSAGE_REGEX
65
+
66
+ module_function
67
+
68
+ # Push this connection's local writes to its remote Turso database.
69
+ def push!(connection = ActiveRecord::Base.connection)
70
+ reraise_local_only { connection.raw_connection.push }
71
+ end
72
+
73
+ # Pull remote writes down into this connection's local replica.
74
+ # Returns the driver's own true/false (whether anything changed).
75
+ def pull!(connection = ActiveRecord::Base.connection)
76
+ reraise_local_only { connection.raw_connection.pull }
77
+ end
78
+
79
+ # Push, then pull. Pushing first means this replica's own writes reach
80
+ # the remote before it re-syncs down, so afterwards this connection is
81
+ # caught up with both its own writes (now durable remotely) and
82
+ # whatever else landed on the remote from other replicas. Call push!
83
+ # or pull! directly when only one direction is wanted.
84
+ def sync!(connection = ActiveRecord::Base.connection)
85
+ push!(connection)
86
+ pull!(connection)
87
+ end
88
+
89
+ def reraise_local_only
90
+ yield
91
+ rescue RuntimeError => e
92
+ raise e unless LOCAL_ONLY_MESSAGE_REGEX.match?(e.message)
93
+
94
+ raise NotSyncedError,
95
+ "#{e.message} -- this connection is local-only; configure remote_url: " \
96
+ "and auth_token: in database.yml to enable sync"
97
+ end
98
+ private_class_method :reraise_local_only
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_record/connection_adapters/sqlite3_adapter"
5
+ require "beagle/turso"
6
+
7
+ module ActiveRecord
8
+ module ConnectionAdapters
9
+ # = ActiveRecord Beagle Turso Adapter
10
+ #
11
+ # Subclasses the stock SQLite3Adapter and reroutes only its raw-execution
12
+ # seam (+perform_query+) onto a beagle-turso Connection. beagle-turso is a
13
+ # libsql/Turso driver: it speaks a SQLite-compatible dialect but has its own
14
+ # native handle rather than the +sqlite3+ gem's +::SQLite3::Database+.
15
+ #
16
+ # Everything ActiveRecord relies on above the raw connection -- SQL
17
+ # generation, quoting, schema introspection (PRAGMA table_xinfo,
18
+ # sqlite_master), type mapping, transactions -- is inherited unchanged from
19
+ # SQLite3Adapter. We swap out:
20
+ #
21
+ # * +new_client+ / +connect+ : open a Beagle::Turso::Database instead of a
22
+ # ::SQLite3::Database, wrapped in a Client that answers the handful of
23
+ # lifecycle/pragma messages the SQLite3Adapter sends a raw connection.
24
+ # * +configure_connection+ : re-assert `PRAGMA foreign_keys = ON` through
25
+ # the real exec path, because the stock DEFAULT_PRAGMA setter is a no-op on
26
+ # our Client (SQLite defaults FKs OFF per-connection).
27
+ # * +perform_query+ : the single method every query flows through
28
+ # (see AbstractAdapter#raw_execute). Reads/RETURNING -> ActiveRecord::Result
29
+ # with columns+rows; writes (incl. data-modifying CTEs) -> affected-row
30
+ # count + last_insert_rowid.
31
+ # * +last_inserted_id+ : fall back to last_insert_rowid when a plain
32
+ # INSERT (no RETURNING) was used.
33
+ class BeagleTursoAdapter < SQLite3Adapter
34
+ ADAPTER_NAME = "BeagleTurso"
35
+
36
+ # A RETURNING clause makes any write yield rows, so it routes to the read
37
+ # (query_result) branch.
38
+ RETURNING_REGEX = /\bRETURNING\b/i
39
+ private_constant :RETURNING_REGEX
40
+
41
+ # A data-modifying CTE: a leading WITH whose *main* statement -- the token
42
+ # right after the CTE definition list's closing paren -- is INSERT / UPDATE
43
+ # / DELETE. AR's write_query? classifies any leading WITH as a READ, so
44
+ # without this such writes would be routed to query_result: the change still
45
+ # persists, but the affected-row count comes back nil and a CTE-INSERT's
46
+ # rowid is lost. The `\)\s*` anchor is deliberate -- it keeps `WITH ... SELECT`
47
+ # reads (and DML keywords that appear inside string literals or identifiers)
48
+ # from being misclassified as writes.
49
+ #
50
+ # Residual limitation: this is a heuristic, not a full SQL parse. A
51
+ # data-modifying CTE whose main verb is not immediately preceded by the CTE
52
+ # list's closing `)` (extremely unusual) could be missed, and a read CTE
53
+ # containing the literal text `) UPDATE`/`) INSERT`/`) DELETE` could be
54
+ # mis-routed. The read branch also re-captures last_insert_rowid whenever the
55
+ # SQL mentions INSERT, so an inserted id is never silently lost even on a miss.
56
+ DATA_MODIFYING_CTE_REGEX = /\A\s*WITH\b[\s\S]*\)\s*(?:INSERT|UPDATE|DELETE)\b/i
57
+ private_constant :DATA_MODIFYING_CTE_REGEX
58
+
59
+ INSERT_REGEX = /\bINSERT\b/i
60
+ private_constant :INSERT_REGEX
61
+
62
+ class << self
63
+ # Open a beagle-turso Database (synced remote when +remote_url+ is
64
+ # present, otherwise local/in-memory) and wrap it so the SQLite3Adapter
65
+ # can drive it as a raw connection.
66
+ def new_client(config)
67
+ database =
68
+ if config[:remote_url]
69
+ Beagle::Turso::Database.open(
70
+ local_path: config[:database].to_s,
71
+ remote_url: config[:remote_url],
72
+ auth_token: config[:auth_token],
73
+ bootstrap_if_empty: config.fetch(:bootstrap_if_empty, true)
74
+ )
75
+ else
76
+ Beagle::Turso::Database.open_local(config[:database].to_s)
77
+ end
78
+ Client.new(database)
79
+ end
80
+ end
81
+
82
+ # Adapts a Beagle::Turso::Database + its connected Connection to the subset
83
+ # of the +sqlite3+ gem's Database surface that SQLite3Adapter pokes at.
84
+ #
85
+ # The execution methods (+query_result+, +execute+, +execute_batch+,
86
+ # +last_insert_rowid+) are the real work. The rest are shims: the adapter's
87
+ # +configure_connection+ sets PRAGMAs via +raw_connection.foo = value+ and
88
+ # calls a couple of sqlite3-specific lifecycle methods that libsql neither
89
+ # needs nor supports; we accept and no-op those so connect/configure runs
90
+ # cleanly.
91
+ class Client
92
+ attr_reader :database, :connection
93
+
94
+ def initialize(database)
95
+ @database = database
96
+ @connection = database.connect
97
+ @closed = false
98
+ end
99
+
100
+ # A bare read of exactly +defer_foreign_keys+ or +read_uncommitted+
101
+ # (no `=`) -- how SQLite3Adapter's DDL and transaction-isolation code
102
+ # reads their state back (e.g. +disable_referential_integrity+'s
103
+ # `query_value("PRAGMA defer_foreign_keys")`, used by every
104
+ # alter_table-based schema change: add_column/remove_column/
105
+ # change_column/etc). libsql/turso does not track queryable state for
106
+ # either of these two specific pragmas -- reading them back yields an
107
+ # empty result set instead of a value, even right after being set.
108
+ # An empty result makes `query_value` return nil, which the caller
109
+ # then interpolates into a follow-up statement
110
+ # (`"PRAGMA defer_foreign_keys = #{nil}"`) -> invalid SQL ("incomplete
111
+ # input"). Both default to OFF/0 per SQLite's docs, and this driver
112
+ # has no way to report a truer answer, so synthesize that default --
113
+ # exactly analogous to the FK-enforcement bridge in
114
+ # +configure_connection+.
115
+ #
116
+ # Deliberately NOT a generic `PRAGMA \w+` allowlist: `PRAGMA
117
+ # foreign_key_check` is also a bare, argument-less pragma read (via
118
+ # SQLite3Adapter#check_all_foreign_keys_valid!), but for it an EMPTY
119
+ # result is the *correct*, meaningful answer ("no FK violations") --
120
+ # synthesizing a fake row for it would make check_all_foreign_keys_valid!
121
+ # (called by ActiveRecord::FixtureSet when
122
+ # verify_foreign_keys_for_fixtures is on -- the Rails 7.1+ app-generator
123
+ # default) raise a false-positive "Foreign key violations found:" on
124
+ # perfectly clean data. Scoping the match to only the two pragmas
125
+ # actually root-caused here means any other bare pragma (including
126
+ # foreign_key_check) falls through to the real driver result instead
127
+ # of a synthesized one -- failing loudly on a genuine future gap
128
+ # rather than silently lying.
129
+ BARE_PRAGMA_READ_REGEX = /\A\s*PRAGMA\s+(defer_foreign_keys|read_uncommitted)\s*\z/i
130
+ private_constant :BARE_PRAGMA_READ_REGEX
131
+
132
+ # --- execution surface used by BeagleTursoAdapter#perform_query ---
133
+ def query_result(sql, params)
134
+ columns, rows = connection.query_result(sql, params)
135
+ if columns.empty? && rows.empty? && (match = BARE_PRAGMA_READ_REGEX.match(sql))
136
+ [[match[1]], [[0]]]
137
+ else
138
+ [columns, rows]
139
+ end
140
+ end
141
+
142
+ def execute(sql, params) = connection.execute(sql, params)
143
+ def execute_batch(sql) = connection.execute_batch(sql)
144
+ def last_insert_rowid = connection.last_insert_rowid
145
+
146
+ # --- sync passthrough (Database-level) ---
147
+ def push = database.push
148
+ def pull = database.pull
149
+
150
+ # --- sqlite3-gem lifecycle shims the SQLite3Adapter drives ---
151
+ # configure_connection only touches this when config[:timeout] is set.
152
+ def busy_handler_timeout=(_)
153
+ nil
154
+ end
155
+ # connected? => !(@raw_connection.nil? || @raw_connection.closed?)
156
+ def closed? = @closed
157
+ # Release the beagle-turso session eagerly rather than waiting for GC.
158
+ # A *synced* Database holds a server-side sync session open; without an
159
+ # explicit close each ActiveRecord disconnect/reconnect (e.g.
160
+ # verify!/db:prepare) leaked one, accumulating on the remote until it
161
+ # reported "database is busy". Close the connection first, then the
162
+ # database (which ends the sync session). Idempotent.
163
+ #
164
+ # @closed is only flipped true AFTER both closes have run: if
165
+ # @connection.close raised and we had set @closed up front, the ensure'd
166
+ # @database.close would be skipped yet closed? would already report true
167
+ # -- the session would leak silently (SQLite3Adapter#disconnect! swallows
168
+ # the exception). The ensure guarantees the database (session) close runs
169
+ # even if the connection close raises, and @closed reflects reality.
170
+ def close
171
+ return if @closed
172
+ begin
173
+ @connection.close
174
+ ensure
175
+ @database.close
176
+ @closed = true
177
+ end
178
+ end
179
+ # SQLite3Adapter#reconnect calls this when reusing a live connection.
180
+ def rollback = execute("ROLLBACK", [])
181
+
182
+ # configure_connection applies DEFAULT_PRAGMAS via `raw_connection.foo = v`.
183
+ # libsql manages journalling/locking itself, so accept and ignore them.
184
+ def method_missing(name, *args)
185
+ name.to_s.end_with?("=") ? nil : super
186
+ end
187
+
188
+ def respond_to_missing?(name, include_private = false)
189
+ name.to_s.end_with?("=") || super
190
+ end
191
+ end
192
+
193
+ private
194
+ # The single seam. AbstractAdapter#raw_execute funnels every statement
195
+ # here and expects an ActiveRecord::Result back, with the notification
196
+ # payload's affected_rows/row_count filled in. cast_result (inherited)
197
+ # returns the Result untouched and affected_rows (inherited) reads
198
+ # Result#affected_rows, so building the Result correctly is all we need.
199
+ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false)
200
+ params = type_casted_binds || []
201
+
202
+ result =
203
+ if batch
204
+ raw_connection.execute_batch(sql)
205
+ ::ActiveRecord::Result.empty
206
+ elsif write_statement?(sql)
207
+ affected = raw_connection.execute(sql, params)
208
+ @last_inserted_rowid = raw_connection.last_insert_rowid
209
+ ::ActiveRecord::Result.empty(affected_rows: affected)
210
+ else
211
+ columns, rows = raw_connection.query_result(sql, params)
212
+ # Belt-and-suspenders: a RETURNING insert lands here (id read from
213
+ # rows by last_inserted_id), and a data-modifying CTE that slips past
214
+ # write_statement? would too -- keep the rowid so an inserted id is
215
+ # never silently lost even on a heuristic miss.
216
+ @last_inserted_rowid = raw_connection.last_insert_rowid if INSERT_REGEX.match?(sql)
217
+ ::ActiveRecord::Result.new(columns, rows)
218
+ end
219
+
220
+ verified!
221
+ notification_payload[:affected_rows] = result.affected_rows
222
+ notification_payload[:row_count] = result.length
223
+ result
224
+ end
225
+
226
+ # Route to the WRITE branch (execute -> affected count + last_insert_rowid)
227
+ # rather than the read branch. A statement is a write when SQLite3's own
228
+ # read/write classifier says so, OR when it is a data-modifying CTE that
229
+ # the classifier misreads as a read (see DATA_MODIFYING_CTE_REGEX). A
230
+ # RETURNING clause always yields rows, so it goes to the read branch even
231
+ # though it mutates. write_query? is a pure predicate here, so calling it a
232
+ # second time is side-effect free.
233
+ def write_statement?(sql)
234
+ return false if RETURNING_REGEX.match?(sql)
235
+ write_query?(sql) || data_modifying_cte?(sql)
236
+ end
237
+
238
+ def data_modifying_cte?(sql)
239
+ DATA_MODIFYING_CTE_REGEX.match?(sql)
240
+ end
241
+
242
+ # For INSERT ... RETURNING "id" the id arrives in the result rows (super).
243
+ # For a plain INSERT (RETURNING unsupported) the result is empty, so fall
244
+ # back to the rowid captured in perform_query's write branch.
245
+ def last_inserted_id(result)
246
+ super || @last_inserted_rowid
247
+ end
248
+
249
+ # Open the beagle-turso connection. Mirrors SQLite3Adapter#connect but
250
+ # without its ::SQLite3-specific ConnectionNotEstablished rescue.
251
+ def connect
252
+ @raw_connection = self.class.new_client(@connection_parameters)
253
+ end
254
+
255
+ # SQLite disables foreign keys per-connection by default; stock
256
+ # SQLite3Adapter turns them on via `raw_connection.foreign_keys = true`
257
+ # (a DEFAULT_PRAGMA setter). On our Client that setter is a swallowed
258
+ # no-op, so FK enforcement would silently be OFF -- a regression vs the
259
+ # stock adapter. Re-assert it here through the real exec path.
260
+ #
261
+ # `super` still runs (check_version + the other DEFAULT_PRAGMA setters);
262
+ # journal_mode/synchronous/mmap_size are moot for in-memory and remote
263
+ # libsql, so leaving those as no-ops is fine.
264
+ def configure_connection
265
+ super
266
+ @raw_connection.execute("PRAGMA foreign_keys = ON", [])
267
+ end
268
+ end
269
+ end
270
+ end
271
+
272
+ ActiveRecord::ConnectionAdapters.register(
273
+ "beagle_turso",
274
+ "ActiveRecord::ConnectionAdapters::BeagleTursoAdapter",
275
+ "active_record/connection_adapters/beagle_turso_adapter"
276
+ )
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module BeagleTurso
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activerecord/beagle_turso/version"
4
+ require "active_record/connection_adapters/beagle_turso_adapter"
5
+ require "active_record/connection_adapters/beagle_turso/sync_manager"
6
+ require "active_record/connection_adapters/beagle_turso/sync_job"
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-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: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '8.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '8.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: beagle-turso
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.13'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.13'
69
+ - !ruby/object:Gem::Dependency
70
+ name: railties
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '8.1'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '8.1'
83
+ description: |
84
+ An ActiveRecord connection adapter for Turso's libsql engine. It subclasses
85
+ the stock SQLite3Adapter and reroutes only the raw-execution seam onto the
86
+ beagle-turso native driver, so schema migrations and model CRUD run against
87
+ a local, in-memory, or synced remote Turso database while inheriting
88
+ SQLite3Adapter's SQL generation, quoting, and schema introspection.
89
+ email:
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files: []
93
+ files:
94
+ - README.md
95
+ - config/recurring.yml.example
96
+ - lib/active_record/connection_adapters/beagle_turso/sync_job.rb
97
+ - lib/active_record/connection_adapters/beagle_turso/sync_manager.rb
98
+ - lib/active_record/connection_adapters/beagle_turso_adapter.rb
99
+ - lib/activerecord-beagle-turso.rb
100
+ - lib/activerecord/beagle_turso/version.rb
101
+ homepage: https://github.com/BeagleSoftwareUK/beagle-turso
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ source_code_uri: https://github.com/BeagleSoftwareUK/beagle-turso
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '3.3'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.5.22
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: ActiveRecord adapter for Turso, backed by the beagle-turso driver.
125
+ test_files: []