sequel-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: 306f254c2d0518ae7d6a078c338d81caa68a96fe2bed3864af6f69fe7fdf1918
4
+ data.tar.gz: 60f7e413c5d21e7bbff28b09d99239029248104d98aef821ae7d5cb6c07267f4
5
+ SHA512:
6
+ metadata.gz: b890a9ad4fb5741b545e116f1a74af5a105526c1abcce1977924a7099abad3557e4277713473b6bef8eb0f6a6cb71a55447352ab7f7676408b9c572f324cdb72
7
+ data.tar.gz: d864638062b76ed2d885f1fdbef74ad3f76eb4c258c0cca0a6156c5181825f398949123bb7866d351e216b53c1e7791b764c51a13097c5745a6d702c0b31f2eb
data/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # sequel-beagle-turso
2
+
3
+ A [Sequel](https://sequel.jeremyevans.net) adapter for the
4
+ [`beagle-turso`](../beagle-turso) driver, so Sequel, [ROM](https://rom-rb.org)
5
+ and [Hanami](https://hanamirb.org) can run on [Turso](https://turso.tech)'s new
6
+ engine: a local SQLite-compatible replica that syncs to Turso Cloud.
7
+
8
+ SQL generation, schema parsing and transactions come from Sequel's shared
9
+ SQLite support. The adapter replaces the plumbing (connections, execution,
10
+ value conversion) and works around the engine's differences from SQLite.
11
+
12
+ ## Install
13
+
14
+ ```ruby
15
+ # Gemfile
16
+ gem "sequel-beagle-turso", "~> 0.1"
17
+ ```
18
+
19
+ Sequel loads the adapter lazily the first time you connect to a
20
+ `beagle-turso://` URL or pass `adapter: :beagle_turso`. To load it yourself,
21
+ `require "sequel-beagle-turso"`.
22
+
23
+ ## Connect
24
+
25
+ ```ruby
26
+ require "sequel"
27
+
28
+ DB = Sequel.connect("beagle-turso:///abs/path/app.db") # absolute path
29
+ DB = Sequel.connect("beagle-turso://./app.db") # relative path
30
+ DB = Sequel.connect("beagle-turso:/") # in memory
31
+ DB = Sequel.connect(adapter: :beagle_turso, database: "app.db")
32
+ ```
33
+
34
+ **A synced replica.** Pass `remote_url:` and `auth_token:` as options, never in
35
+ the URL. A URL carrying `auth_token` raises, because URLs end up in logs:
36
+
37
+ ```ruby
38
+ DB = Sequel.connect(
39
+ "beagle-turso:///abs/path/app.db",
40
+ remote_url: ENV.fetch("TURSO_DATABASE_URL"),
41
+ auth_token: ENV.fetch("TURSO_AUTH_TOKEN")
42
+ )
43
+ ```
44
+
45
+ The adapter removes `auth_token` from the `Database`'s options once the replica
46
+ is open. Push and pull go through the replica: `DB.turso_database.push` and
47
+ `DB.turso_database.pull`. See the
48
+ [driver README](../beagle-turso#readme) for the durability model, and for which
49
+ Turso database to use.
50
+
51
+ **Sharing a replica.** If you already have an open `Beagle::Turso::Database`,
52
+ pass it as `turso_database:` and the `Database` uses it instead of opening one.
53
+ The adapter never closes a replica it was handed.
54
+
55
+ **Closing.** `DB.disconnect` closes the pooled connections and leaves the
56
+ replica open. `DB.close_replica` disconnects, then closes the replica that this
57
+ `Database` opened. A synced replica is held by one OS process at a time, so
58
+ close it before something else needs the file.
59
+
60
+ Other options: `timeout:` (ms, default 5000, see Writes), `transaction_mode:`,
61
+ and `bootstrap_if_empty:` (passed to the driver).
62
+
63
+ ## Hanami and ROM
64
+
65
+ ```ruby
66
+ Hanami.app.configure_provider :db do
67
+ config.gateway(:default) do |gw|
68
+ gw.database_url = "beagle-turso://#{Hanami.app.root.join("db/app.db")}"
69
+ gw.connection_options(remote_url: ENV["TURSO_DATABASE_URL"], auth_token: ENV["TURSO_AUTH_TOKEN"])
70
+ end
71
+ end
72
+ ```
73
+
74
+ - ROM's gateway keeps its own copy of the connection options. The adapter
75
+ scrubs the token from the `Database` it builds, but not from that copy, so
76
+ don't log the gateway's options.
77
+ - The `hanami db` commands don't support the `beagle-turso` scheme. Run
78
+ migrations through `ROM::SQL::Migration::Migrator` on the app's gateway, with
79
+ the server stopped (a synced replica is held by one process).
80
+
81
+ ## Writes
82
+
83
+ The engine allows one writer at a time and reports contention immediately.
84
+
85
+ - Outside a transaction, and at `BEGIN`, the adapter waits in Ruby (with
86
+ backoff) for up to `timeout:` ms (default 5000), then raises
87
+ `Sequel::DatabaseLockTimeout`.
88
+ - Explicit transactions use `BEGIN IMMEDIATE`, so a transaction waits for the
89
+ write lock at its start instead of failing partway. `transaction_mode:`
90
+ (`:deferred`, `:immediate`, `:exclusive`) changes the default, and
91
+ `db.transaction(mode: ...)` overrides it per transaction.
92
+ - Inside a transaction, contention surfaces as an error (and the transaction
93
+ rolls back) instead of being retried.
94
+ - Don't set `PRAGMA busy_timeout`. Its wait holds the GVL, so the writer that
95
+ holds the lock can't commit.
96
+ - Because of `BEGIN IMMEDIATE`, read-only `db.transaction { }` blocks and
97
+ `Dataset#paged_each` take the write lock and queue behind writers. Pass
98
+ `mode: :deferred` for read-only transactions.
99
+ - Statements run inside a manual `db.run("BEGIN")` aren't seen as a transaction
100
+ by Sequel, so they get the autocommit busy retry. Use `db.transaction`.
101
+ - A `db.run` with several statements (`"INSERT ...; INSERT ..."`) is never
102
+ retried, since a retry could re-run a statement that already committed. If
103
+ the write lock is taken it raises `Sequel::DatabaseLockTimeout` at once, and
104
+ statements before the failing one may already be committed. Run statements
105
+ one at a time, or in a `db.transaction`. (A `;` inside a string literal also
106
+ counts as a second statement.)
107
+
108
+ **Pool size.** The engine allows many connections (32 and more) to one
109
+ replica, but only one writer at a time. `max_connections` defaults to 4. Under
110
+ write-heavy load a smaller pool makes writers queue in Sequel's pool rather
111
+ than busy-retry against each other. An in-memory database defaults to one
112
+ connection.
113
+
114
+ ## Fork (Puma, Unicorn, forking job runners)
115
+
116
+ A replica belongs to the process that opened it. After `fork`, the child's
117
+ inherited pooled connections raise `Sequel::DatabaseDisconnectError` and are
118
+ dropped, and opening new ones raises `Sequel::DatabaseConnectionError`
119
+ (`Beagle::Turso::ForkedError`). `DB.disconnect` before forking is not enough,
120
+ because the replica itself can't cross the fork. Create the `Sequel::Database`
121
+ in each process after it forks (for Puma, in `on_worker_boot`), and don't open
122
+ it in the parent. A replica file is locked by one process at a time, so give
123
+ each process its own replica file.
124
+
125
+ ## Falcon and fibers
126
+
127
+ Call `Sequel.extension :fiber_concurrency` before connecting, so the pool hands
128
+ out connections per fiber rather than per thread. The driver README's
129
+ [Threads, fibers and fork](../beagle-turso#threads-fibers-and-fork) section
130
+ covers the GVL and Falcon notes, including what runs on Ruby 3.3 and 4.0.
131
+
132
+ ## Limitations
133
+
134
+ - The sync engine's own tables (`turso_cdc`, `turso_cdc_version`,
135
+ `turso_sync_last_change_id`) are hidden from `tables` and `views`, so cleaners
136
+ and schema dumpers can't truncate the change log.
137
+ - The engine doesn't support generated columns, `WITHOUT ROWID` tables, fts5 or
138
+ `ATTACH`.
139
+ - Declared-type arguments (`varchar(255)`, `numeric(12,0)`) aren't reported by
140
+ the engine, so `schema` has no `:max_length` for string columns.
141
+ - `LIKE` is always case-insensitive: the engine ignores
142
+ `PRAGMA case_sensitive_like`.
143
+ - `alter_table` operations that rebuild the table fail, and roll back cleanly,
144
+ if another table's trigger references the rebuilt table.
145
+ - Dropping a self-referencing table that has rows works outside a transaction
146
+ only (the adapter retries with foreign keys off). Inside a transaction it
147
+ raises.
148
+ - **Known engine issue: self-referencing foreign keys.** Turso's engine has open bugs with foreign keys that reference their own table
149
+ (such as `parent_id`): a multi-row `DELETE`, `UPDATE` or `DROP` that SQLite accepts can fail with
150
+ `FOREIGN KEY constraint failed`, and `ON DELETE SET NULL` / `CASCADE` can leave
151
+ rows behind or orphaned
152
+ ([tursodatabase/turso#8713](https://github.com/tursodatabase/turso/issues/8713),
153
+ [#9279](https://github.com/tursodatabase/turso/issues/9279),
154
+ [#9293](https://github.com/tursodatabase/turso/issues/9293),
155
+ [#9294](https://github.com/tursodatabase/turso/issues/9294)). Foreign keys
156
+ between two different tables are unaffected. Workaround: avoid `ON DELETE`
157
+ actions on self-referencing foreign keys, and delete children before parents,
158
+ one level at a time.
159
+
160
+ - **Known engine issue: insert ids on MVCC replicas.** On a replica in MVCC
161
+ journal mode with change data capture on (every synced replica of a
162
+ `--tursodb` remote), `last_insert_rowid` returns the change-log id, not the
163
+ inserted row's rowid
164
+ ([tursodatabase/turso#9551](https://github.com/tursodatabase/turso/issues/9551)).
165
+ The adapter doesn't use it: `Dataset#insert` appends `RETURNING rowid` to the
166
+ statement and returns the last row's rowid (`nil` if nothing was inserted,
167
+ such as `insert_ignore` hitting a duplicate), so ids are correct everywhere.
168
+ Code that calls the driver's `last_insert_rowid` itself is still affected.
169
+ - **`rename_table` is refused on synced replicas.** The sync engine doesn't
170
+ capture `ALTER TABLE ... RENAME TO` or `RENAME COLUMN` for sync, so the remote
171
+ never sees the rename and later writes to the table are lost on push. When
172
+ `turso_database.synced?`, `rename_table` raises `Sequel::Error`. Local-only
173
+ replicas are unaffected. Workaround: create the new table, copy the rows and
174
+ drop the old one, or run the rename against the remote database directly.
175
+ - **Table rebuilds avoid `RENAME`.** Operations that rebuild a table
176
+ (`set_column_type`, `set_column_allow_null`, `set_column_default`, constraint
177
+ changes, adding a unique column) copy the rows to a backup table, drop and
178
+ recreate the table, copy back and drop the backup, so nothing is renamed. On
179
+ a synced replica `drop_column` and `rename_column` go through the same
180
+ rebuild too (the native `DROP COLUMN` loses unpushed rows, and
181
+ `RENAME COLUMN` isn't synced). Rebuilds therefore copy the data twice, and
182
+ **the first push after a rebuild on a synced replica raises a `SyncError`
183
+ once; push again and the data converges** (`Syncer` logs it once and
184
+ retries). Rebuilds (Sequel's stock ones too) have these limits:
185
+ - triggers on the table are lost;
186
+ - the `AUTOINCREMENT` counter restarts from the current max id, so a deleted
187
+ top id can be reused;
188
+ - partial indexes are not recreated;
189
+ - a column's `COLLATE` is dropped;
190
+ - a foreign key's `DEFERRABLE` is dropped (the engine doesn't report it).
191
+
192
+ On a synced replica, `rename_column` also keeps the table's own foreign keys
193
+ (re-added with the new column name, self-references included), but it is
194
+ refused when another table's foreign key references the column: the rebuild
195
+ can't update that reference, and both tables would become unwritable. Rebuild
196
+ the referencing table as well, or apply the rename on the remote and pull.
197
+ `drop_column` of a foreign key column drops that foreign key with it.
198
+ - **Insert return values.** `Dataset#insert` returns the last inserted row's
199
+ rowid. An `ON CONFLICT DO UPDATE` upsert returns the updated row's rowid, and
200
+ an insert that is ignored (`insert_ignore`, `ON CONFLICT DO NOTHING`) returns
201
+ `nil`; SQLite's `last_insert_rowid` would return a stale value there.
202
+
203
+ The full list of Sequel integration tests excluded for engine gaps, with a
204
+ reason for each, is in
205
+ [`spec/integration_known_failures.txt`](spec/integration_known_failures.txt).
206
+
207
+ ## Testing
208
+
209
+ ```bash
210
+ bundle exec rspec # the adapter's own specs, including a ROM smoke test
211
+ script/sequel-integration # Sequel's own integration suite
212
+ ```
213
+
214
+ `script/sequel-integration` clones the Sequel release matching the installed
215
+ version, runs its `spec/integration` suite against this adapter and excludes the
216
+ tests in `spec/integration_known_failures.txt` by exact name. The remote specs
217
+ need `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` for a dedicated test database,
218
+ and skip cleanly without them.
219
+
220
+ ## License
221
+
222
+ [MIT](https://opensource.org/licenses/MIT).
@@ -0,0 +1,4 @@
1
+ # Sequel loads an adapter by requiring "sequel/adapters/<url scheme>". URI
2
+ # schemes cannot contain "_", so the URL form is beagle-turso:// and this file
3
+ # exists only so that lookup finds the adapter.
4
+ require_relative "beagle_turso"
@@ -0,0 +1,628 @@
1
+ require "beagle/turso"
2
+ require "bigdecimal"
3
+ require "sequel/adapters/shared/sqlite"
4
+
5
+ module Sequel
6
+ # Sequel adapter for the beagle-turso driver. SQL generation, schema parsing
7
+ # and transactions come from Sequel's shared SQLite support; this file only
8
+ # replaces the plumbing (connections, execution, value conversion) and
9
+ # papers over the engine's differences from SQLite.
10
+ module BeagleTurso
11
+ FALSE_VALUES = (%w[0 false f no n].each(&:freeze) + [0]).freeze
12
+
13
+ blob = Object.new
14
+ def blob.call(s)
15
+ Sequel::SQL::Blob.new(s.to_s)
16
+ end
17
+
18
+ boolean = Object.new
19
+ def boolean.call(s)
20
+ s = s.downcase if s.is_a?(String)
21
+ !FALSE_VALUES.include?(s)
22
+ end
23
+
24
+ date = Object.new
25
+ def date.call(s)
26
+ case s
27
+ when String then Sequel.string_to_date(s)
28
+ when Integer then Date.jd(s)
29
+ when Float then Date.jd(s.to_i)
30
+ else raise Sequel::Error, "unhandled type when converting to date: #{s.inspect} (#{s.class.inspect})"
31
+ end
32
+ end
33
+
34
+ integer = Object.new
35
+ def integer.call(s)
36
+ s.to_i
37
+ end
38
+
39
+ float = Object.new
40
+ def float.call(s)
41
+ s.to_f
42
+ end
43
+
44
+ numeric = Object.new
45
+ def numeric.call(s)
46
+ s = s.to_s unless s.is_a?(String)
47
+ BigDecimal(s)
48
+ rescue ArgumentError
49
+ s
50
+ end
51
+
52
+ time = Object.new
53
+ def time.call(s)
54
+ case s
55
+ when String then Sequel.string_to_time(s)
56
+ when Integer then Sequel::SQLTime.create(s / 3600, (s % 3600) / 60, s % 60)
57
+ when Float
58
+ secs, frac = s.divmod(1)
59
+ Sequel::SQLTime.create(secs / 3600, (secs % 3600) / 60, secs % 60, (frac * 1_000_000).round)
60
+ else raise Sequel::Error, "unhandled type when converting to time: #{s.inspect} (#{s.class.inspect})"
61
+ end
62
+ end
63
+
64
+ # Declared base type name (lowercase) -> converter. datetime/timestamp are
65
+ # added per Database so Sequel's timezone settings apply.
66
+ SQLITE_TYPES = {}
67
+ {
68
+ %w[date] => date,
69
+ %w[time] => time,
70
+ %w[bit bool boolean] => boolean,
71
+ %w[integer smallint mediumint int bigint] => integer,
72
+ %w[numeric decimal money] => numeric,
73
+ %w[float double real dec fixed] + ["double precision"] => float,
74
+ %w[blob] => blob
75
+ }.each { |names, conv| names.each { |n| SQLITE_TYPES[n] = conv } }
76
+ SQLITE_TYPES.freeze
77
+
78
+ # Thread-safe retry count. Lives in an ivar of the Database, and a frozen
79
+ # Database must still be able to count, so it can't be a plain Integer.
80
+ class BusyCounter
81
+ def initialize
82
+ @mutex = Mutex.new
83
+ @value = 0
84
+ end
85
+
86
+ def increment
87
+ @mutex.synchronize { @value += 1 }
88
+ end
89
+
90
+ def value
91
+ @mutex.synchronize { @value }
92
+ end
93
+ end
94
+ private_constant :BusyCounter
95
+
96
+ class Database < Sequel::Database
97
+ include Sequel::SQLite::DatabaseMethods
98
+
99
+ # The driver prefixes engine messages ("query failed: ..."); Sequel's
100
+ # SQLite error regexps (some anchored at \A) expect the bare message.
101
+ ENGINE_PREFIX = /\A(?:open failed|connect failed|query failed|sync failed|database is busy): /
102
+
103
+ # Tables the Turso sync engine creates on a synced replica; never user tables.
104
+ SYNC_ENGINE_TABLES = %w[turso_cdc turso_cdc_version turso_sync_last_change_id].freeze
105
+
106
+ RETURNING_CLAUSE = /\bRETURNING\b/i
107
+
108
+ # String literals, quoted identifiers, and comments: where the word
109
+ # RETURNING is data, not syntax.
110
+ NON_CODE = %r{'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]|--[^\n]*|/\*.*?\*/}m
111
+
112
+ set_adapter_scheme :beagle_turso
113
+ # The URL form: URI schemes can't contain "_" (see adapters/beagle-turso.rb).
114
+ Sequel::ADAPTER_MAP[:"beagle-turso"] = self
115
+
116
+ # Same path handling as Sequel's sqlite adapter: beagle-turso:///abs.db,
117
+ # beagle-turso://./rel.db, beagle-turso:/ (memory).
118
+ def self.uri_to_options(uri)
119
+ database = if uri.host.nil?
120
+ case uri.path
121
+ when "/" then nil
122
+ when nil then uri.opaque
123
+ else uri.path
124
+ end
125
+ else
126
+ "#{uri.host}#{uri.path}"
127
+ end
128
+ { database: database }
129
+ end
130
+ private_class_method :uri_to_options
131
+
132
+ attr_reader :conversion_procs, :turso_database
133
+
134
+ # One pooled connection. The replica itself is opened once, in
135
+ # adapter_initialize, and shared by every connection.
136
+ def connect(_server)
137
+ conn = @turso_database.connect
138
+ connection_pragmas.each { |s| log_connection_yield(s, conn) { conn.execute_batch(s) } }
139
+ conn
140
+ rescue Beagle::Turso::Error => e
141
+ raise Sequel.convert_exception_class(e, Sequel::DatabaseConnectionError)
142
+ end
143
+
144
+ def disconnect_connection(conn)
145
+ conn.close
146
+ end
147
+
148
+ # Closes pooled connections and the replica this Database opened. A
149
+ # replica passed in as turso_database: belongs to the caller and is left
150
+ # open. Idempotent.
151
+ def close_replica
152
+ disconnect
153
+ @turso_database.close if @owns_turso_database && !@turso_database.closed?
154
+ nil
155
+ end
156
+
157
+ # Row-returning SQL. Yields (and returns) [columns, decl_types, rows].
158
+ def execute(sql, opts = OPTS)
159
+ result = _execute(sql, opts) { |conn| conn.query_result_with_types(sql, []) }
160
+ yield result if defined?(yield)
161
+ result
162
+ end
163
+
164
+ def execute_dui(sql, opts = OPTS)
165
+ _execute(sql, opts) { |conn| conn.execute(sql, []) }
166
+ end
167
+
168
+ # execute_batch runs every statement in sql. Retrying a batch after a
169
+ # busy error could re-run a statement that already committed, so only a
170
+ # single statement gets the busy retry; a batch runs once.
171
+ def execute_ddl(sql, opts = OPTS)
172
+ _execute(sql, opts, busy_retry: single_statement?(sql)) { |conn| conn.execute_batch(sql) }
173
+ nil
174
+ end
175
+
176
+ # Returns the rowid of the last row inserted, or nil if none was.
177
+ #
178
+ # Not conn.last_insert_rowid: on a replica in MVCC journal mode with
179
+ # change data capture on (every synced replica of a --tursodb remote)
180
+ # the engine leaves the CDC change id there, so it is wrong
181
+ # (tursodatabase/turso#9551). INSERT ... RETURNING is correct, so the
182
+ # statement gets RETURNING rowid appended. rowid always exists: the
183
+ # engine has no WITHOUT ROWID tables. Several rows (INSERT ... SELECT,
184
+ # multi-row VALUES) return the last one, like SQLite's
185
+ # last_insert_rowid; an insert that changed nothing (OR IGNORE, ON
186
+ # CONFLICT DO NOTHING) returns nil. The logged SQL is the original. RETURNING
187
+ # goes on its own line so a trailing -- comment (sql_comments) can't swallow it.
188
+ #
189
+ # An insert that already has RETURNING is run as is, and its first
190
+ # column in the last row is the result. (Dataset#insert sends those
191
+ # through fetch, and insert_select too, not through here.)
192
+ #
193
+ # Note: insert(select_ds) / INSERT ... SELECT materialises every
194
+ # returned rowid just to take the last one.
195
+ def execute_insert(sql, opts = OPTS)
196
+ run_sql = with_returning_rowid(sql)
197
+ _execute(sql, opts) do |conn|
198
+ _, _, rows = conn.query_result_with_types(run_sql, [])
199
+ rows.last&.first
200
+ end
201
+ end
202
+
203
+ # SQLite stores timestamps as text, integers (unix time) or reals (julian day).
204
+ def to_application_timestamp(s)
205
+ case s
206
+ when String then super
207
+ when Integer then super(Time.at(s).to_s)
208
+ when Float then super(DateTime.jd(s).to_s)
209
+ else raise Sequel::Error, "unhandled type when converting to timestamp: #{s.inspect} (#{s.class.inspect})"
210
+ end
211
+ end
212
+
213
+ # The engine checks a table's self-references row by row when dropping
214
+ # it, so a self-referencing table with rows can't be dropped while
215
+ # foreign keys are on (SQLite drops it). When nothing else could be
216
+ # referencing the rows, drop it with foreign keys briefly off.
217
+ def drop_table(*names)
218
+ opts = names.last.is_a?(Hash) ? names.pop : OPTS
219
+ names.each do |name|
220
+ super(name, opts)
221
+ rescue ForeignKeyConstraintViolation
222
+ raise unless drop_self_referencing_table(name, opts)
223
+ end
224
+ nil
225
+ end
226
+
227
+ # Refused on a synced replica, see refuse_rename_when_synced.
228
+ def rename_table(name, new_name)
229
+ refuse_rename_when_synced("rename_table")
230
+ super
231
+ end
232
+
233
+ # Sequel's check sends SELECT NULL through connection_execute_method
234
+ # (execute_batch), which rejects any statement that returns a row.
235
+ def valid_connection?(conn)
236
+ sql = valid_connection_sql
237
+ log_connection_yield(sql, conn) { conn.query(sql, []) }
238
+ true
239
+ rescue Sequel::DatabaseError, *database_error_classes
240
+ false
241
+ end
242
+
243
+ def freeze
244
+ @conversion_procs.freeze
245
+ super
246
+ end
247
+
248
+ private
249
+
250
+ # True if it dropped the table. PRAGMA foreign_keys is per connection and
251
+ # ignored inside a transaction, so this needs to own the connection.
252
+ def drop_self_referencing_table(name, opts)
253
+ synchronize(opts[:server]) do |conn|
254
+ return false if _trans(conn) || !self_referencing_only?(name)
255
+
256
+ execute_ddl("PRAGMA foreign_keys = 0", opts)
257
+ begin
258
+ execute_ddl(drop_table_sql(name, opts), opts)
259
+ ensure
260
+ execute_ddl("PRAGMA foreign_keys = 1", opts)
261
+ end
262
+ remove_cached_schema(name)
263
+ true
264
+ end
265
+ end
266
+
267
+ # The table references itself, and no other table with rows references it.
268
+ def self_referencing_only?(name)
269
+ table = name.to_s
270
+ return false unless foreign_key_list(name).any? { |fk| fk[:table].to_s == table }
271
+
272
+ tables.reject { |t| t.to_s == table }.none? do |other|
273
+ foreign_key_list(other).any? { |fk| fk[:table].to_s == table } && !from(other).empty?
274
+ end
275
+ end
276
+
277
+ def adapter_initialize
278
+ @conversion_procs = SQLITE_TYPES.dup
279
+ @conversion_procs["datetime"] = @conversion_procs["timestamp"] = method(:to_application_timestamp)
280
+ set_integer_booleans
281
+ reject_url_credentials
282
+ # The engine allows one writer and reports contention immediately; we
283
+ # wait in Ruby (see retry_busy), never with PRAGMA busy_timeout, whose
284
+ # wait holds the GVL so the lock holder can't commit.
285
+ @busy_retries = BusyCounter.new
286
+ @busy_timeout = typecast_value_integer(@opts.fetch(:timeout, 5000)) / 1000.0
287
+ # Take the write lock at BEGIN, so a transaction that can't get it
288
+ # waits there instead of failing mid-way.
289
+ self.transaction_mode = (@opts[:transaction_mode] || :immediate).to_sym
290
+ @turso_database, @owns_turso_database = open_turso_database
291
+ scrub_credentials
292
+ sqlite_version # fail fast, and avoid a surprise first query later
293
+ end
294
+
295
+ # Runs before a replica is opened or adopted, so it covers both paths.
296
+ def reject_url_credentials
297
+ return unless url_has_auth_token?(@opts[:uri].to_s)
298
+ raise Error, "pass auth_token: as a connection option, not in the URL (URLs end up in logs)"
299
+ end
300
+
301
+ def url_has_auth_token?(url)
302
+ query = URI.parse(url).query
303
+ query && URI.decode_www_form(query).any? { |k, _| k.downcase == "auth_token" }
304
+ rescue ArgumentError, URI::Error
305
+ url.match?(/[?&]auth(_|%5f)token(=|&|\z)/i)
306
+ end
307
+
308
+ def open_turso_database
309
+ return [@opts[:turso_database], false] if @opts[:turso_database]
310
+ path = @opts[:database].to_s
311
+ path = ":memory:" if path.empty?
312
+ db = Beagle::Turso::Database.open(
313
+ local_path: path,
314
+ remote_url: @opts[:remote_url],
315
+ auth_token: @opts[:auth_token],
316
+ bootstrap_if_empty: typecast_value_boolean(@opts.fetch(:bootstrap_if_empty, true))
317
+ )
318
+ [db, true]
319
+ rescue Beagle::Turso::Error => e
320
+ raise Sequel.convert_exception_class(e, Sequel::DatabaseConnectionError)
321
+ end
322
+
323
+ # The replica holds the token natively; nothing on this side needs it
324
+ # again, so don't let it sit in opts (inspect, logging, error reports).
325
+ def scrub_credentials
326
+ @opts.delete(:auth_token)
327
+ if @opts[:orig_opts].is_a?(Hash)
328
+ @opts[:orig_opts] = @opts[:orig_opts].reject { |k, _| k.to_s == "auth_token" }
329
+ end
330
+ end
331
+
332
+ # Busy retries so far (for specs).
333
+ def busy_retry_count
334
+ @busy_retries.value
335
+ end
336
+
337
+ def _execute(sql, opts, busy_retry: true)
338
+ synchronize(opts[:server]) do |conn|
339
+ # The retry sits inside the logged block: one log line per statement,
340
+ # at error level only if it finally fails. Inside a transaction a
341
+ # BusyError means the transaction is dead; it must surface (and
342
+ # roll back), not be retried.
343
+ log_connection_yield(sql, conn) { !busy_retry || _trans(conn) ? yield(conn) : retry_busy { yield conn } }
344
+ end
345
+ rescue Beagle::Turso::Error => e
346
+ raise_error(e)
347
+ end
348
+
349
+ # Conservative: a ";" inside a string literal also counts as a second
350
+ # statement, which only costs that statement its busy retry.
351
+ def single_statement?(sql)
352
+ !sql.strip.sub(/;\s*\z/, "").include?(";")
353
+ end
354
+
355
+ # Same as the shared version, but the BEGIN is retried inside its log
356
+ # entry (one line, not one per attempt).
357
+ def begin_new_transaction(conn, opts)
358
+ mode = opts[:mode] || @transaction_mode
359
+ sql = Sequel::SQLite::DatabaseMethods::TRANSACTION_MODE[mode] or
360
+ raise Error, "transaction :mode must be one of: :deferred, :immediate, :exclusive, nil"
361
+ log_connection_yield(sql, conn) { retry_busy { conn.execute_batch(sql) } }
362
+ set_transaction_isolation(conn, opts)
363
+ end
364
+
365
+ # Retries a statement that lost the single-writer race, with capped
366
+ # exponential backoff, for up to timeout: ms. sleep releases the GVL and
367
+ # yields to a fiber scheduler, so the writer holding the lock can finish.
368
+ def retry_busy
369
+ deadline = nil
370
+ delay = 0.002
371
+ begin
372
+ yield
373
+ rescue Beagle::Turso::BusyError => e
374
+ raise if e.message.include?("snapshot is stale")
375
+
376
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
377
+ deadline ||= now + @busy_timeout
378
+ raise if now + delay > deadline
379
+
380
+ @busy_retries.increment
381
+ sleep delay
382
+ delay = [delay * 2, 0.1].min
383
+ retry
384
+ end
385
+ end
386
+
387
+ # Sequel runs BEGIN/COMMIT/SAVEPOINT with conn.public_send(method, sql):
388
+ # one argument. Connection#execute takes (sql, params); execute_batch(sql)
389
+ # takes one.
390
+ def connection_execute_method
391
+ :execute_batch
392
+ end
393
+
394
+ def database_error_classes
395
+ [Beagle::Turso::Error]
396
+ end
397
+
398
+ def database_specific_error_class(exception, opts)
399
+ return DatabaseDisconnectError if disconnect_error?(exception, opts)
400
+
401
+ if exception.is_a?(Beagle::Turso::BusyError)
402
+ return exception.message.include?("snapshot is stale") ? SerializationFailure : DatabaseLockTimeout
403
+ end
404
+
405
+ message = exception.message.sub(ENGINE_PREFIX, "")
406
+ database_error_regexps.each { |regexp, klass| return klass if regexp.match?(message) }
407
+ nil
408
+ end
409
+
410
+ # A closed or fork-inherited connection is dead: let the pool drop it.
411
+ def disconnect_error?(exception, opts)
412
+ exception.is_a?(Beagle::Turso::ClosedError) || exception.is_a?(Beagle::Turso::ForkedError) || super
413
+ end
414
+
415
+ # Only reads @opts: Sequel calls this before adapter_initialize.
416
+ def connection_pool_default_options
417
+ o = super
418
+ memory = @opts[:database].to_s.empty? || @opts[:database] == ":memory:"
419
+ memory && !@opts[:turso_database] ? o.merge(max_connections: 1) : o
420
+ end
421
+
422
+ def dataset_class_default
423
+ Dataset
424
+ end
425
+
426
+ # The engine reports `to` as "" (SQLite: NULL) for a foreign key that
427
+ # references the parent's primary key implicitly. Sequel would turn that
428
+ # into a bogus column and break table rebuilds in alter_table.
429
+ def _foreign_key_list_ds(table)
430
+ super.with_row_proc(lambda do |row|
431
+ row[:to] = nil if row[:to] == ""
432
+ row
433
+ end)
434
+ end
435
+
436
+ # For DEFAULT (expr) SQLite reports dflt_value as expr; the engine keeps
437
+ # the parentheses. A default that isn't parenthesised is a literal, and
438
+ # no literal starts with "(", so dropping one outer pair is exact. The
439
+ # engine also writes a negative number as "- 1" (SQLite: "-1"), which
440
+ # Sequel can't parse into a Ruby default.
441
+ def _parse_pragma_ds(table_name, opts)
442
+ super.with_row_proc(lambda do |row|
443
+ d = row[:dflt_value]
444
+ if d.is_a?(String)
445
+ d = d[1...-1] if d.start_with?("(") && d.end_with?(")")
446
+ row[:dflt_value] = d.sub(/\A-\s+(?=\d)/, "-")
447
+ end
448
+ row
449
+ end)
450
+ end
451
+
452
+ # Sequel's version turns foreign keys off, rebuilds in a transaction and
453
+ # turns them back on, each through its own pool checkout. PRAGMA
454
+ # foreign_keys is per connection, so on a warm pool those can land on
455
+ # different connections: the rebuild runs with foreign keys on, and a
456
+ # connection is left with them off. Hold one connection throughout.
457
+ def apply_alter_table(table, ops)
458
+ synchronize { super }
459
+ end
460
+
461
+ # Sequel rebuilds a table by renaming it to a backup, creating the new
462
+ # table, copying and dropping the backup. The engine ignores
463
+ # legacy_alter_table and rewrites other tables' foreign keys (and views)
464
+ # to the backup name, and on a synced replica a RENAME never reaches the
465
+ # remote at all. So rebuild without any RENAME: copy the rows to a backup
466
+ # table made from the new definition, drop the original, recreate it,
467
+ # copy back, drop the backup. Nothing refers to the backup name. (Used
468
+ # for every rebuild, local or synced, so there is one code path.)
469
+ def duplicate_table(table, opts = OPTS)
470
+ rename, create, insert, drop, *rest = super
471
+ qt = quote_schema_table(table)
472
+ bt = drop.to_s.delete_prefix("DROP TABLE ")
473
+ create_prefix = "CREATE TABLE #{qt}("
474
+ insert_prefix = "INSERT INTO #{qt}("
475
+ unless rename == "ALTER TABLE #{qt} RENAME TO #{bt}" && create.start_with?(create_prefix) &&
476
+ insert.start_with?(insert_prefix) && insert.end_with?(" FROM #{bt}")
477
+ raise Error, "unexpected table rebuild SQL from Sequel #{Sequel::VERSION}; cannot rebuild #{qt}"
478
+ end
479
+
480
+ columns = insert.delete_prefix(insert_prefix).delete_suffix(" FROM #{bt}")
481
+ new_cols = columns.split(") SELECT ", 2).first
482
+ [
483
+ "CREATE TABLE #{bt}(#{create.delete_prefix(create_prefix)}",
484
+ "INSERT INTO #{bt}(#{columns} FROM #{qt}",
485
+ "DROP TABLE #{qt}",
486
+ create,
487
+ "INSERT INTO #{qt}(#{new_cols}) SELECT #{new_cols} FROM #{bt}",
488
+ "DROP TABLE #{bt}",
489
+ *rest
490
+ ]
491
+ end
492
+
493
+ # AUTOINCREMENT tables get engine bookkeeping tables in sqlite_master.
494
+ def tables_and_views(filter, opts)
495
+ super.reject do |name|
496
+ name = name.to_s
497
+ name.start_with?("__turso_internal_") || SYNC_ENGINE_TABLES.include?(name)
498
+ end
499
+ end
500
+
501
+ # sql with RETURNING rowid added, unless it has a RETURNING clause. The
502
+ # clause goes at the end of the statement: before a trailing ";" and any
503
+ # trailing comment (a "--" comment would otherwise swallow it). Literals,
504
+ # quoted identifiers and comments are masked first, so "RETURNING"
505
+ # inside data doesn't count as a clause.
506
+ def with_returning_rowid(sql)
507
+ # Comments become spaces (they aren't part of the statement), literals
508
+ # and identifiers become "x" (they are).
509
+ masked = sql.gsub(NON_CODE) { |m| (m.start_with?("--", "/*") ? " " : "x") * m.length }
510
+ return sql if RETURNING_CLAUSE.match?(masked)
511
+
512
+ at = masked.sub(/;\s*\z/, "").rstrip.length
513
+ "#{sql[0, at]} RETURNING rowid#{sql[at..]}"
514
+ end
515
+
516
+ # The engine doesn't capture ALTER TABLE ... RENAME TO / RENAME COLUMN
517
+ # for sync (turso_core translate/alter.rs emits no CDC for them), so on
518
+ # a synced replica the remote never sees the rename and every later
519
+ # write to the table is lost on push.
520
+ def refuse_rename_when_synced(what)
521
+ return unless @turso_database.synced?
522
+
523
+ raise Error, "#{what} is refused on a synced replica: the Turso sync engine does not replicate " \
524
+ "ALTER TABLE ... RENAME TO / RENAME COLUMN to the remote, so later writes to the table would be " \
525
+ "lost on push (no upstream fix yet). Create the new table, copy the rows and drop the old one, " \
526
+ "or run the rename against the remote database directly."
527
+ end
528
+
529
+ def alter_table_sql(table, op)
530
+ # On a synced replica, native DROP COLUMN loses unpushed rows and
531
+ # RENAME COLUMN never reaches the remote (the engine doesn't capture
532
+ # them), so both go through the RENAME-free rebuild.
533
+ if @turso_database.synced?
534
+ case op[:op]
535
+ when :drop_column
536
+ ocp = lambda { |oc| oc.delete_if { |c| c.to_s == op[:name].to_s } }
537
+ return duplicate_table(table, old_columns_proc: ocp) { |cols| cols.delete_if { |s| s[:name].to_s == op[:name].to_s } }
538
+ when :rename_column
539
+ return rebuild_renaming_column(table, op)
540
+ end
541
+ end
542
+ super
543
+ end
544
+
545
+ # Column rename as a rebuild. Sequel's fallback renames the column first
546
+ # and then looks up UNIQUE constraints and indexes by the old name, so it
547
+ # loses them. Take them before the rebuild and replay them with the new
548
+ # name: inline/composite UNIQUE (autoindexes) become column or table
549
+ # constraints, every other index mentioning the column is recreated.
550
+ def rebuild_renaming_column(table, op)
551
+ old = op[:name].to_s
552
+ new = op[:new_name]
553
+ rn = ->(cols) { cols.map { |c| c.to_s == old ? new : c } }
554
+ pks = schema(table).select { |_, c| c[:primary_key] }.map { |n, _| n.to_s }
555
+ auto_all = indexes(table, only_autocreated: true)
556
+ auto = auto_all.values.select { |h| h[:unique] && h[:columns].map(&:to_s) != pks }
557
+ single = auto.select { |h| h[:columns].length == 1 }.map { |h| rn.(h[:columns]).first.to_s }
558
+ constraints = auto.select { |h| h[:columns].length > 1 }.map { |h| { type: :unique, columns: rn.(h[:columns]) } }
559
+ refuse_rename_of_referenced_column(table, old, pks)
560
+ # Sequel would copy the foreign keys under the old column names; copy
561
+ # them ourselves with the renamed column. Self-references rename the
562
+ # referenced column (key) too. (The engine doesn't report DEFERRABLE.)
563
+ fks = foreign_key_list(table).map do |fk|
564
+ self_ref = fk[:table].to_s == table.to_s
565
+ fk.merge(type: :foreign_key, columns: rn.(fk[:columns]), key: (self_ref && fk[:key] ? rn.(fk[:key]) : fk[:key]))
566
+ end
567
+ constraints.concat(fks)
568
+ named = indexes(table).select { |n, h| !auto_all.key?(n) && h[:columns].map(&:to_s).include?(old) }
569
+
570
+ ncp = ->(nc) { nc.map! { |c| c.to_s == old ? new : c } }
571
+ sqls = duplicate_table(table, new_columns_proc: ncp, no_unique: true, no_foreign_keys: true, constraints: constraints) do |cols|
572
+ cols.each do |c|
573
+ c[:name] = new if c[:name].to_s == old
574
+ c[:unique] = true if single.include?(c[:name].to_s) && !c[:primary_key]
575
+ end
576
+ end
577
+ sqls + named.map { |name, h| alter_table_sql(table, h.merge(op: :add_index, name: name, columns: rn.(h[:columns]))) }
578
+ end
579
+
580
+ # A rebuild of this table can't update another table's foreign key that
581
+ # names the column, and the stale reference makes both tables unwritable.
582
+ def refuse_rename_of_referenced_column(table, old, pks)
583
+ referencing = tables.reject { |t| t.to_s == table.to_s }.select do |other|
584
+ foreign_key_list(other).any? do |fk|
585
+ fk[:table].to_s == table.to_s && (fk[:key] || pks).map(&:to_s).include?(old)
586
+ end
587
+ end
588
+ return if referencing.empty?
589
+
590
+ raise Error, "rename_column of #{table}.#{old} is refused on a synced replica: " \
591
+ "#{referencing.join(', ')} reference(s) it with a foreign key, and rebuilding #{table} cannot " \
592
+ "update that reference. Rebuild the referencing table(s) as well, or apply the rename on the " \
593
+ "remote database and pull."
594
+ end
595
+
596
+ def support_without_rowid?
597
+ false
598
+ end
599
+ end
600
+
601
+ class Dataset < Sequel::Dataset
602
+ include Sequel::SQLite::DatasetMethods
603
+
604
+ def fetch_rows(sql)
605
+ execute(sql) do |columns, decl_types, rows|
606
+ cps = db.conversion_procs
607
+ cols = columns.each_with_index.map { |c, i| [output_identifier(c), cps[base_type_name(decl_types[i])]] }
608
+ self.columns = cols.map(&:first)
609
+ rows.each do |values|
610
+ row = {}
611
+ cols.each_with_index do |(name, conv), i|
612
+ v = values[i]
613
+ row[name] = conv && !v.nil? ? conv.call(v) : v
614
+ end
615
+ yield row
616
+ end
617
+ end
618
+ end
619
+
620
+ private
621
+
622
+ # "decimal(10,2)" -> "decimal"; nil (expression columns) stays nil.
623
+ def base_type_name(t)
624
+ (t =~ /\A(.*?)\(/ ? Regexp.last_match(1) : t).downcase if t
625
+ end
626
+ end
627
+ end
628
+ end
@@ -0,0 +1,5 @@
1
+ module Sequel
2
+ module BeagleTurso
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ require "sequel"
2
+ require "sequel/beagle_turso/version"
3
+ require "sequel/adapters/beagle_turso"
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel-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-10-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.100'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.100'
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.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.3'
41
+ description: |
42
+ A Sequel adapter (URL scheme beagle-turso://) that runs Sequel -- and ROM /
43
+ Hanami repositories on top of it -- on a beagle-turso database: a local
44
+ SQLite-compatible replica that syncs to Turso Cloud. SQL generation, schema
45
+ parsing and transactions come from Sequel's shared SQLite support.
46
+ email:
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - README.md
52
+ - lib/sequel-beagle-turso.rb
53
+ - lib/sequel/adapters/beagle-turso.rb
54
+ - lib/sequel/adapters/beagle_turso.rb
55
+ - lib/sequel/beagle_turso/version.rb
56
+ homepage: https://github.com/BeagleSoftwareUK/beagle-turso
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ source_code_uri: https://github.com/BeagleSoftwareUK/beagle-turso
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.3'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.5.22
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Sequel adapter for Turso's local-first engine, backed by the beagle-turso
80
+ driver.
81
+ test_files: []