activerecord-turso 0.2.2 → 0.3.2
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 +4 -4
- data/README.md +9 -2
- data/lib/active_record/connection_adapters/turso_adapter/connection_management.rb +32 -9
- data/lib/active_record/connection_adapters/turso_adapter/database_statements.rb +4 -0
- data/lib/active_record/connection_adapters/turso_adapter/error_translation.rb +11 -2
- data/lib/active_record/connection_adapters/turso_adapter/transaction_management.rb +3 -1
- data/lib/active_record/tasks/turso_database_tasks.rb +125 -0
- data/lib/activerecord-turso/version.rb +1 -1
- data/lib/activerecord-turso.rb +9 -0
- data/lib/turso/ar/connection.rb +17 -4
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dc77c288fa28d1c9872459245f9afe593a510dac77d5e0630bdb58d7bab4b90
|
|
4
|
+
data.tar.gz: 192ddeb9179b451e2844ce533f1bbaf7a96bae2c493a1b0a322398401dae838a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 502dce7acddd392a64255e2e59d3fa7290196218968456dc4a4032bdfe83fde510f31625ae260bb9f4d979fc5af1626ac3dfc4484608b32dd223cb37d75d9dfc
|
|
7
|
+
data.tar.gz: 6f8570e0a2e17733238babaaf41a17dba6d5cf2e8ca1b983d883dd4a3b0a0fc7373975c9fb21d4bac0e273de3feaa1377f263e68f5123e703761c73067b840ce
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@ ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible db).
|
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
Production ready for ActiveRecord 8.1 applications. The underlying `turso` gem releases the GVL during blocking operations, supports fiber-aware connection ownership, and uses native batch execution.
|
|
7
|
+
Production ready for **embedded/local** ActiveRecord 8.1 applications using the Turso SQLite-compatible database engine. This adapter does **not** support remote `libsql://` URLs or Turso cloud authentication. The underlying `turso` gem releases the GVL during blocking operations, supports fiber-aware connection ownership, and uses native batch execution.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ Production ready for ActiveRecord 8.1 applications. The underlying `turso` gem r
|
|
|
14
14
|
|
|
15
15
|
## Runtime dependencies
|
|
16
16
|
|
|
17
|
-
This adapter uses the `turso` gem and does **not** require the `sqlite3` Ruby gem at runtime.
|
|
17
|
+
This adapter uses the `turso` gem and does **not** require the `sqlite3` Ruby gem at runtime. The `sqlite3` gem is used only in the Rails conformance test harness.
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
@@ -114,6 +114,7 @@ development:
|
|
|
114
114
|
- `transaction(concurrent: true)` requires the same database connection to be held for the entire retry loop. Rails' connection pool may reap the connection between retries, which can silently break MVCC semantics. Use this only when you understand the pooling behavior of your app.
|
|
115
115
|
- `lock!`, `with_lock`, and `lock_version` are not meaningful under MVCC. Do not use them inside concurrent transactions.
|
|
116
116
|
- If the retry limit is exhausted, the conflict is raised as `ActiveRecord::StatementInvalid`. You must handle it in application code.
|
|
117
|
+
- Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside a concurrent transaction because ActiveRecord opens its own internal transaction for each model change. Use raw SQL (`execute`, `exec_query`) or bulk operations (`insert_all`, `update_all`, `update_columns`) instead.
|
|
117
118
|
|
|
118
119
|
### Full-text search (Tantivy)
|
|
119
120
|
|
|
@@ -246,6 +247,12 @@ TURSO_TEST_EXPERIMENTAL_FEATURES=generated_columns bundle exec rake test
|
|
|
246
247
|
|
|
247
248
|
The CI workflow in `.github/workflows/ci.yml` runs all three configurations automatically.
|
|
248
249
|
|
|
250
|
+
Run the Rails sqlite3 adapter conformance suite:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
bundle exec rake test:conformance
|
|
254
|
+
```
|
|
255
|
+
|
|
249
256
|
## License
|
|
250
257
|
|
|
251
258
|
MIT
|
|
@@ -6,6 +6,7 @@ module ActiveRecord
|
|
|
6
6
|
module ConnectionManagement
|
|
7
7
|
def self.included(base)
|
|
8
8
|
base.extend(ClassMethods)
|
|
9
|
+
base.set_callback :checkout, :before, :reconnect_for_execution_context
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
module ClassMethods
|
|
@@ -17,12 +18,28 @@ module ActiveRecord
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def active?
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
connected?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def connected?
|
|
25
|
+
!(@raw_connection.nil? || @raw_connection.closed?)
|
|
22
26
|
rescue ::Turso::Error
|
|
23
27
|
false
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
def disconnect!
|
|
31
|
+
super
|
|
32
|
+
|
|
33
|
+
if @raw_connection && !@raw_connection.closed?
|
|
34
|
+
begin
|
|
35
|
+
@raw_connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
36
|
+
rescue ::Turso::Error
|
|
37
|
+
end
|
|
38
|
+
@raw_connection.close
|
|
39
|
+
end
|
|
40
|
+
@raw_connection = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
26
43
|
def reconnect!(**)
|
|
27
44
|
@lock.synchronize do
|
|
28
45
|
disconnect!
|
|
@@ -31,13 +48,6 @@ module ActiveRecord
|
|
|
31
48
|
end
|
|
32
49
|
end
|
|
33
50
|
|
|
34
|
-
def disconnect!
|
|
35
|
-
@lock.synchronize do
|
|
36
|
-
@raw_connection&.close
|
|
37
|
-
@raw_connection = nil
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
51
|
def raw_connection
|
|
42
52
|
@lock.synchronize { @raw_connection }
|
|
43
53
|
end
|
|
@@ -62,10 +72,23 @@ module ActiveRecord
|
|
|
62
72
|
|
|
63
73
|
execute("PRAGMA foreign_keys = ON")
|
|
64
74
|
|
|
75
|
+
autocheckpoint = @config.fetch(:wal_autocheckpoint, 1000)
|
|
76
|
+
execute("PRAGMA wal_autocheckpoint = #{autocheckpoint.to_i}")
|
|
77
|
+
|
|
65
78
|
timeout = @config[:busy_timeout] || @config[:timeout] || 5000
|
|
66
79
|
@raw_connection.busy_timeout = timeout
|
|
67
80
|
|
|
81
|
+
query_timeout = @config[:query_timeout] || 30_000
|
|
82
|
+
@raw_connection.query_timeout = query_timeout
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def reconnect_for_execution_context
|
|
88
|
+
return unless @raw_connection
|
|
89
|
+
return if @raw_connection.owned_by_current_execution_context?
|
|
68
90
|
|
|
91
|
+
reconnect!
|
|
69
92
|
end
|
|
70
93
|
end
|
|
71
94
|
end
|
|
@@ -69,6 +69,10 @@ module ActiveRecord
|
|
|
69
69
|
type_map = build_type_map(stmt)
|
|
70
70
|
ActiveRecord::Result.new(columns, rows, type_map, affected_rows: affected_rows)
|
|
71
71
|
end
|
|
72
|
+
rescue
|
|
73
|
+
@statements.delete(sql) if prepare
|
|
74
|
+
stmt.close
|
|
75
|
+
raise
|
|
72
76
|
ensure
|
|
73
77
|
stmt.close unless prepare
|
|
74
78
|
end
|
|
@@ -5,8 +5,7 @@ module ActiveRecord
|
|
|
5
5
|
class TursoAdapter < SQLite3Adapter
|
|
6
6
|
module ErrorTranslation
|
|
7
7
|
def translate_exception(exception, message:, sql:, binds:)
|
|
8
|
-
|
|
9
|
-
case cause
|
|
8
|
+
case exception
|
|
10
9
|
when ::Turso::ConstraintException
|
|
11
10
|
translate_constraint_error(message, sql, binds)
|
|
12
11
|
when ::Turso::NotADatabaseException
|
|
@@ -25,11 +24,21 @@ module ActiveRecord
|
|
|
25
24
|
ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
|
|
26
25
|
when ::Turso::MisuseException
|
|
27
26
|
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
27
|
+
when ::Turso::Exception
|
|
28
|
+
ActiveRecord::ConnectionFailed.new(message, connection_pool: pool)
|
|
28
29
|
else
|
|
29
30
|
super
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def retryable_connection_error?(exception)
|
|
35
|
+
super || exception.is_a?(::Turso::Exception)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def retryable_query_error?(exception)
|
|
39
|
+
super || exception.is_a?(::Turso::Exception)
|
|
40
|
+
end
|
|
41
|
+
|
|
33
42
|
private
|
|
34
43
|
|
|
35
44
|
def translate_constraint_error(message, sql, binds)
|
|
@@ -97,7 +97,9 @@ module ActiveRecord
|
|
|
97
97
|
|
|
98
98
|
def rollback_if_active
|
|
99
99
|
return unless @raw_connection && !@raw_connection.closed?
|
|
100
|
-
@raw_connection.execute("ROLLBACK")
|
|
100
|
+
@raw_connection.execute("ROLLBACK")
|
|
101
|
+
rescue ::Turso::Error
|
|
102
|
+
disconnect! rescue nil
|
|
101
103
|
end
|
|
102
104
|
|
|
103
105
|
def backoff(base_delay_ms, retries)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/tasks/database_tasks"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Tasks
|
|
7
|
+
class TursoDatabaseTasks < SQLiteDatabaseTasks
|
|
8
|
+
def initialize(db_config, root = nil)
|
|
9
|
+
@configuration = db_config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
path = configuration.database
|
|
14
|
+
return if path == ":memory:"
|
|
15
|
+
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
17
|
+
establish_connection(configuration)
|
|
18
|
+
connection.execute("SELECT 1")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def drop
|
|
22
|
+
path = configuration.database
|
|
23
|
+
return if path == ":memory:"
|
|
24
|
+
|
|
25
|
+
disconnect!
|
|
26
|
+
FileUtils.rm_f(path)
|
|
27
|
+
FileUtils.rm_f("#{path}-wal")
|
|
28
|
+
FileUtils.rm_f("#{path}-shm")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def purge
|
|
32
|
+
drop
|
|
33
|
+
create
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def charset
|
|
37
|
+
"UTF-8"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def charset_collation
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def structure_dump(filename, extra_flags)
|
|
45
|
+
establish_connection(configuration)
|
|
46
|
+
io = File.open(filename, "w")
|
|
47
|
+
io.puts("PRAGMA foreign_keys = OFF;")
|
|
48
|
+
|
|
49
|
+
tables = connection.query_values(<<~SQL, "SCHEMA")
|
|
50
|
+
SELECT name FROM sqlite_master
|
|
51
|
+
WHERE type = 'table'
|
|
52
|
+
AND name NOT LIKE 'sqlite_%'
|
|
53
|
+
AND name NOT LIKE '__turso_internal_%'
|
|
54
|
+
ORDER BY name
|
|
55
|
+
SQL
|
|
56
|
+
|
|
57
|
+
tables.each do |table|
|
|
58
|
+
sql = connection.query_value(<<~SQL, "SCHEMA")
|
|
59
|
+
SELECT sql FROM sqlite_master WHERE name = #{connection.quote(table)}
|
|
60
|
+
SQL
|
|
61
|
+
io.puts("#{sql};") if sql
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
indexes = connection.query_values(<<~SQL, "SCHEMA")
|
|
65
|
+
SELECT name FROM sqlite_master
|
|
66
|
+
WHERE type = 'index'
|
|
67
|
+
AND name NOT LIKE 'sqlite_%'
|
|
68
|
+
AND name NOT LIKE '__turso_internal_%'
|
|
69
|
+
AND sql IS NOT NULL
|
|
70
|
+
ORDER BY name
|
|
71
|
+
SQL
|
|
72
|
+
|
|
73
|
+
indexes.each do |index|
|
|
74
|
+
sql = connection.query_value(<<~SQL, "SCHEMA")
|
|
75
|
+
SELECT sql FROM sqlite_master WHERE name = #{connection.quote(index)}
|
|
76
|
+
SQL
|
|
77
|
+
io.puts("#{sql};") if sql
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
views = connection.query_values(<<~SQL, "SCHEMA")
|
|
81
|
+
SELECT name FROM sqlite_master
|
|
82
|
+
WHERE type = 'view'
|
|
83
|
+
AND name NOT LIKE 'sqlite_%'
|
|
84
|
+
ORDER BY name
|
|
85
|
+
SQL
|
|
86
|
+
|
|
87
|
+
views.each do |view|
|
|
88
|
+
sql = connection.query_value(<<~SQL, "SCHEMA")
|
|
89
|
+
SELECT sql FROM sqlite_master WHERE name = #{connection.quote(view)}
|
|
90
|
+
SQL
|
|
91
|
+
io.puts("#{sql};") if sql
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
io.puts("PRAGMA foreign_keys = ON;")
|
|
95
|
+
io.close
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def structure_load(filename, extra_flags)
|
|
99
|
+
establish_connection(configuration)
|
|
100
|
+
sql = File.read(filename)
|
|
101
|
+
ActiveRecord::Base.connection.raw_connection.execute_batch(sql)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def configuration
|
|
107
|
+
@configuration
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def connection
|
|
111
|
+
ActiveRecord::Base.connection
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def establish_connection(config)
|
|
115
|
+
@configuration = config
|
|
116
|
+
ActiveRecord::Base.establish_connection(config)
|
|
117
|
+
ActiveRecord::Base.connection.connect!
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def disconnect!
|
|
121
|
+
ActiveRecord::Base.connection_pool.disconnect!
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/activerecord-turso.rb
CHANGED
|
@@ -12,3 +12,12 @@ ActiveRecord::ConnectionAdapters.register(
|
|
|
12
12
|
"ActiveRecord::ConnectionAdapters::TursoAdapter",
|
|
13
13
|
"active_record/connection_adapters/turso_adapter"
|
|
14
14
|
)
|
|
15
|
+
|
|
16
|
+
require "active_record/tasks/database_tasks"
|
|
17
|
+
require_relative "active_record/tasks/turso_database_tasks"
|
|
18
|
+
|
|
19
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(/turso/, "ActiveRecord::Tasks::TursoDatabaseTasks")
|
|
20
|
+
|
|
21
|
+
at_exit do
|
|
22
|
+
ActiveRecord::Base.connection_pool&.disconnect! rescue nil
|
|
23
|
+
end
|
data/lib/turso/ar/connection.rb
CHANGED
|
@@ -8,20 +8,25 @@ module Turso
|
|
|
8
8
|
extend Forwardable
|
|
9
9
|
|
|
10
10
|
def_delegators :@db, :close, :closed?, :changes, :total_changes,
|
|
11
|
-
:last_insert_rowid, :prepare, :interrupt, :busy_timeout
|
|
11
|
+
:last_insert_rowid, :prepare, :interrupt, :busy_timeout=,
|
|
12
|
+
:query_timeout, :query_timeout=
|
|
12
13
|
|
|
13
14
|
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
|
14
15
|
DEFAULT_QUERY_TIMEOUT_MS = 30_000
|
|
15
16
|
|
|
16
17
|
def initialize(config)
|
|
17
18
|
@config = config
|
|
19
|
+
@owner = owner_token
|
|
18
20
|
db_opts = {
|
|
19
21
|
busy_timeout: config[:busy_timeout] || config[:timeout] || DEFAULT_BUSY_TIMEOUT_MS,
|
|
20
22
|
query_timeout: config[:query_timeout] || DEFAULT_QUERY_TIMEOUT_MS
|
|
21
23
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
if config[:experimental_features]
|
|
25
|
+
features = Array(config[:experimental_features]).map(&:to_s).reject(&:empty?)
|
|
26
|
+
db_opts[:experimental_features] = features.join(",") unless features.empty?
|
|
27
|
+
end
|
|
28
|
+
@database = ::Turso::Database.new(config[:database].to_s, **db_opts)
|
|
29
|
+
@db = @database.connection
|
|
25
30
|
@db.busy_timeout = db_opts[:busy_timeout]
|
|
26
31
|
@db.query_timeout = db_opts[:query_timeout]
|
|
27
32
|
end
|
|
@@ -34,6 +39,10 @@ module Turso
|
|
|
34
39
|
!@db.closed?
|
|
35
40
|
end
|
|
36
41
|
|
|
42
|
+
def owned_by_current_execution_context?
|
|
43
|
+
@owner == owner_token
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
def disconnect!
|
|
38
47
|
@db.close unless @db.closed?
|
|
39
48
|
end
|
|
@@ -53,6 +62,10 @@ module Turso
|
|
|
53
62
|
|
|
54
63
|
private
|
|
55
64
|
|
|
65
|
+
def owner_token
|
|
66
|
+
[Thread.current.object_id, Fiber.current.object_id]
|
|
67
|
+
end
|
|
68
|
+
|
|
56
69
|
def normalize_binds(binds)
|
|
57
70
|
binds.map do |value|
|
|
58
71
|
case value
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-turso
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben D'Angelo
|
|
@@ -63,6 +63,7 @@ files:
|
|
|
63
63
|
- lib/active_record/connection_adapters/turso_adapter/schema_statements.rb
|
|
64
64
|
- lib/active_record/connection_adapters/turso_adapter/statement_pool.rb
|
|
65
65
|
- lib/active_record/connection_adapters/turso_adapter/transaction_management.rb
|
|
66
|
+
- lib/active_record/tasks/turso_database_tasks.rb
|
|
66
67
|
- lib/activerecord-turso.rb
|
|
67
68
|
- lib/activerecord-turso/error.rb
|
|
68
69
|
- lib/activerecord-turso/version.rb
|
|
@@ -84,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
84
85
|
- !ruby/object:Gem::Version
|
|
85
86
|
version: '0'
|
|
86
87
|
requirements: []
|
|
87
|
-
rubygems_version: 4.0.
|
|
88
|
+
rubygems_version: 4.0.18
|
|
88
89
|
specification_version: 4
|
|
89
|
-
summary: ActiveRecord adapter for Turso
|
|
90
|
+
summary: Embedded ActiveRecord adapter for Turso (SQLite-compatible)
|
|
90
91
|
test_files: []
|