activerecord-turso 0.2.0 → 0.2.1
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 +29 -23
- data/lib/active_record/connection_adapters/turso_adapter/connection_management.rb +4 -2
- data/lib/active_record/connection_adapters/turso_adapter/database_statements.rb +16 -27
- data/lib/active_record/connection_adapters/turso_adapter/error_translation.rb +10 -10
- data/lib/active_record/connection_adapters/turso_adapter/schema_statements.rb +1 -0
- data/lib/active_record/connection_adapters/turso_adapter/statement_pool.rb +1 -1
- data/lib/active_record/connection_adapters/turso_adapter/transaction_management.rb +5 -2
- data/lib/active_record/connection_adapters/turso_adapter.rb +11 -1
- data/lib/activerecord-turso/version.rb +1 -1
- data/lib/turso/ar/connection.rb +8 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04f8ecc726fa450fadcf8b819a04663b3035bed1c1b5f592d362ec07664d2f14
|
|
4
|
+
data.tar.gz: 4ce85117e6f562ead6d2defeaaa5c70fccc2c62492d9916c798e56bcf32acfca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d18e249a927642d705959341439129523cb3f2763ab699a8c2dfbc1f38d8f52b3b831a96f140a95518f3e932a9da348184af605525d354a54893419f332c0aa
|
|
7
|
+
data.tar.gz: 8b1cec4617634414e11928453aeed21e2c382fe4a36357ca435b601f337f8b9ca3fb732f035db02ebd8a0fe8f7dfc35db961c384faffd2cf8937ea612ec6363e
|
data/README.md
CHANGED
|
@@ -8,10 +8,14 @@ This adapter is under **active** development. It can run basic ActiveRecord oper
|
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
|
-
- Ruby >= 3.
|
|
11
|
+
- Ruby >= 3.2
|
|
12
12
|
- ActiveRecord >= 8.0, < 8.2
|
|
13
13
|
- The `turso` Ruby gem
|
|
14
14
|
|
|
15
|
+
## Runtime dependencies
|
|
16
|
+
|
|
17
|
+
This adapter uses the `turso` gem and does **not** require the `sqlite3` Ruby gem at runtime.
|
|
18
|
+
|
|
15
19
|
## Installation
|
|
16
20
|
|
|
17
21
|
Add to your Gemfile:
|
|
@@ -47,7 +51,23 @@ end
|
|
|
47
51
|
Post.create!(title: "Hello", body: "World", published: true)
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
## Recommended production configuration
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
production:
|
|
58
|
+
adapter: turso
|
|
59
|
+
database: db/production.sqlite3
|
|
60
|
+
journal_mode: wal
|
|
61
|
+
pool: 5
|
|
62
|
+
timeout: 5000
|
|
63
|
+
busy_timeout: 5000
|
|
64
|
+
query_timeout: 30000
|
|
65
|
+
experimental_features: "index_method"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### MVCC / BEGIN CONCURRENT (experimental)
|
|
69
|
+
|
|
70
|
+
⚠️ **Experimental.** Do not use in production unless you understand the caveats below. Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside `transaction(concurrent: true)`.
|
|
51
71
|
|
|
52
72
|
Turso supports `BEGIN CONCURRENT` for optimistic, multi-writer transactions. To opt in, pass `concurrent: true` to `transaction`:
|
|
53
73
|
|
|
@@ -124,8 +144,6 @@ development:
|
|
|
124
144
|
journal_mode: mvcc
|
|
125
145
|
busy_timeout: 5000
|
|
126
146
|
|
|
127
|
-
# Enable experimental Turso features when needed (e.g., custom index methods for FTS).
|
|
128
|
-
# Pass a comma-separated string or an array of feature names.
|
|
129
147
|
experimental_features: "index_method"
|
|
130
148
|
```
|
|
131
149
|
|
|
@@ -133,7 +151,6 @@ Use `concurrent: true` inside the same pinned connection:
|
|
|
133
151
|
|
|
134
152
|
```ruby
|
|
135
153
|
ActiveRecord::Base.connection.transaction(concurrent: true) do
|
|
136
|
-
# read-modify-write using raw SQL or bulk operations
|
|
137
154
|
end
|
|
138
155
|
```
|
|
139
156
|
|
|
@@ -143,20 +160,6 @@ Caveats:
|
|
|
143
160
|
- 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.
|
|
144
161
|
- FTS custom index modules are not supported in MVCC mode.
|
|
145
162
|
|
|
146
|
-
## Recommended production configuration
|
|
147
|
-
|
|
148
|
-
```yaml
|
|
149
|
-
production:
|
|
150
|
-
adapter: turso
|
|
151
|
-
database: db/production.sqlite3
|
|
152
|
-
journal_mode: wal # Use mvcc only if you need concurrent writers and understand the caveats above
|
|
153
|
-
pool: 5
|
|
154
|
-
timeout: 5000 # busy_timeout default in milliseconds
|
|
155
|
-
query_timeout: 30000 # maximum time a single query may run
|
|
156
|
-
busy_timeout: 5000 # explicit busy timeout (falls back to timeout)
|
|
157
|
-
experimental_features: "index_method"
|
|
158
|
-
```
|
|
159
|
-
|
|
160
163
|
## Limitations and Risks
|
|
161
164
|
|
|
162
165
|
The following limitations apply to the current implementation. Read this section carefully before deploying to production.
|
|
@@ -176,7 +179,7 @@ The adapter's batch execution path splits multi-statement SQL on semicolons. Thi
|
|
|
176
179
|
- ActiveRecord expects transactions to commit unless the database returns an error. With `BEGIN CONCURRENT`, the commit can fail with a snapshot conflict and must be retried.
|
|
177
180
|
- The retry loop must run on the same connection. Rails' connection pool is not MVCC-aware and may return the connection to the pool between retries.
|
|
178
181
|
- Do not combine concurrent transactions with pessimistic locking (`lock!`, `with_lock`, `lock_version`).
|
|
179
|
-
- Normal model persistence (`create!`, `save!`, `update!`, `touch`) is unsupported inside a concurrent transaction because ActiveRecord opens an internal transaction for each model change.
|
|
182
|
+
- Normal model persistence (`create!`, `save!`, `update!`, `touch`) is unsupported inside a concurrent transaction because ActiveRecord opens an internal transaction for each model change.
|
|
180
183
|
|
|
181
184
|
Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns, and prefer raw SQL or bulk operations inside the block.
|
|
182
185
|
|
|
@@ -188,13 +191,16 @@ The adapter uses a bounded statement pool with a default limit inherited from Ra
|
|
|
188
191
|
|
|
189
192
|
Only ActiveRecord 8.1 is installed in the primary development environment. ActiveRecord 8.0 compatibility is validated through CI. If you run into 8.0-specific issues, please report them.
|
|
190
193
|
|
|
191
|
-
### 6.
|
|
194
|
+
### 6. INSERT RETURNING is disabled (not supported by Turso)
|
|
195
|
+
|
|
196
|
+
The underlying Turso SQLite build does not support `INSERT ... RETURNING` syntax. The adapter reports `supports_insert_returning?` as `false`, so ActiveRecord falls back to `last_insert_rowid()` for retrieving inserted IDs.
|
|
197
|
+
|
|
198
|
+
### 7. Some SQLite-specific features are unsupported or conservatively flagged
|
|
192
199
|
|
|
193
200
|
- Transaction isolation levels other than the default are reported as unsupported (`supports_transaction_isolation?` returns `false`) because Turso remote connections do not provide shared-cache read-uncommitted semantics.
|
|
194
|
-
- `insert_returning` is enabled only when the reported SQLite version is `>= 3.35.0`.
|
|
195
201
|
- `insert_on_conflict` is enabled only when the reported SQLite version is `>= 3.24.0`.
|
|
196
202
|
|
|
197
|
-
###
|
|
203
|
+
### 8. `execute_batch` in the underlying bindings is a Ruby-side fallback
|
|
198
204
|
|
|
199
205
|
The `turso` gem provides `DB#execute_batch` as a convenience that splits and executes statements one by one. It does not use a native batch API, so it carries the same semicolon-splitting risk as item 2 above.
|
|
200
206
|
|
|
@@ -54,6 +54,9 @@ module ActiveRecord
|
|
|
54
54
|
execute("PRAGMA journal_mode = #{mode}")
|
|
55
55
|
if @config[:journal_mode].to_s.downcase == "mvcc"
|
|
56
56
|
@mvcc_enabled = true
|
|
57
|
+
ActiveRecord::Base.logger&.warn(
|
|
58
|
+
"Turso journal_mode: mvcc is experimental. Concurrent transactions require careful connection handling."
|
|
59
|
+
)
|
|
57
60
|
end
|
|
58
61
|
end
|
|
59
62
|
|
|
@@ -62,8 +65,7 @@ module ActiveRecord
|
|
|
62
65
|
timeout = @config[:busy_timeout] || @config[:timeout] || 5000
|
|
63
66
|
@raw_connection.busy_timeout = timeout
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
@raw_connection.query_timeout = query_timeout
|
|
68
|
+
|
|
67
69
|
end
|
|
68
70
|
end
|
|
69
71
|
end
|
|
@@ -6,7 +6,7 @@ module ActiveRecord
|
|
|
6
6
|
module DatabaseStatements
|
|
7
7
|
def execute(sql, name = nil)
|
|
8
8
|
materialize_transactions
|
|
9
|
-
raw_execute(sql, name)
|
|
9
|
+
raw_execute(sql, name)&.to_a
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def last_inserted_id(sql)
|
|
@@ -25,22 +25,13 @@ module ActiveRecord
|
|
|
25
25
|
binds
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def select_rows(arel, name = nil)
|
|
28
|
+
def select_rows(arel, name = nil, binds = [], _options = {})
|
|
29
29
|
arel = arel_from_relation(arel)
|
|
30
|
-
sql, binds = to_sql_and_binds(arel)
|
|
30
|
+
sql, binds = to_sql_and_binds(arel, binds)
|
|
31
31
|
type_casted_binds = type_casted_binds(binds)
|
|
32
32
|
|
|
33
33
|
log(sql, name, binds, type_casted_binds) do
|
|
34
|
-
|
|
35
|
-
stmt = conn.prepare(sql)
|
|
36
|
-
stmt.bind_positional(type_casted_binds) unless type_casted_binds.empty?
|
|
37
|
-
rows = []
|
|
38
|
-
while stmt.step == 1
|
|
39
|
-
rows << stmt.row.to_a
|
|
40
|
-
end
|
|
41
|
-
stmt.finalize
|
|
42
|
-
rows
|
|
43
|
-
end
|
|
34
|
+
exec_query(sql, name, binds, prepare: false).rows
|
|
44
35
|
end
|
|
45
36
|
end
|
|
46
37
|
|
|
@@ -57,21 +48,20 @@ module ActiveRecord
|
|
|
57
48
|
end
|
|
58
49
|
|
|
59
50
|
stmt.reset if prepare
|
|
60
|
-
stmt.
|
|
51
|
+
stmt.bind(*type_casted_binds) unless type_casted_binds.empty?
|
|
61
52
|
|
|
62
53
|
begin
|
|
63
|
-
if write_query?(sql)
|
|
64
|
-
|
|
54
|
+
if write_query?(sql) && !sql.match?(/\bRETURNING\b/i)
|
|
55
|
+
result = stmt.run
|
|
56
|
+
affected_rows = result[:changes]
|
|
65
57
|
verified!
|
|
66
58
|
notification_payload[:affected_rows] = affected_rows
|
|
67
59
|
notification_payload[:row_count] = 0
|
|
68
60
|
ActiveRecord::Result.empty(affected_rows: affected_rows)
|
|
69
61
|
else
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
rows << stmt.row.to_a
|
|
74
|
-
end
|
|
62
|
+
columns_info = stmt.columns
|
|
63
|
+
columns = columns_info.map { |c| c[:name] }
|
|
64
|
+
rows = stmt.all.map(&:to_a)
|
|
75
65
|
affected_rows = raw_connection.changes
|
|
76
66
|
verified!
|
|
77
67
|
notification_payload[:affected_rows] = affected_rows
|
|
@@ -80,7 +70,7 @@ module ActiveRecord
|
|
|
80
70
|
ActiveRecord::Result.new(columns, rows, type_map, affected_rows: affected_rows)
|
|
81
71
|
end
|
|
82
72
|
ensure
|
|
83
|
-
stmt.
|
|
73
|
+
stmt.close unless prepare
|
|
84
74
|
end
|
|
85
75
|
end
|
|
86
76
|
|
|
@@ -89,11 +79,11 @@ module ActiveRecord
|
|
|
89
79
|
old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys")
|
|
90
80
|
|
|
91
81
|
begin
|
|
92
|
-
execute("PRAGMA defer_foreign_keys = ON")
|
|
82
|
+
execute("PRAGMA defer_foreign_keys = ON") unless old_defer_foreign_keys.nil?
|
|
93
83
|
execute("PRAGMA foreign_keys = OFF")
|
|
94
84
|
yield
|
|
95
85
|
ensure
|
|
96
|
-
|
|
86
|
+
unless old_defer_foreign_keys.nil?
|
|
97
87
|
execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}")
|
|
98
88
|
end
|
|
99
89
|
execute("PRAGMA foreign_keys = #{old_foreign_keys}")
|
|
@@ -104,9 +94,8 @@ module ActiveRecord
|
|
|
104
94
|
|
|
105
95
|
def build_type_map(stmt)
|
|
106
96
|
type_map = {}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
decltype = stmt.respond_to?(:column_decltype) ? stmt.column_decltype(i) : nil
|
|
97
|
+
stmt.columns.each_with_index do |col, i|
|
|
98
|
+
decltype = col[:type]
|
|
110
99
|
next unless decltype
|
|
111
100
|
type_map[i] = decltype_to_type(decltype)
|
|
112
101
|
end
|
|
@@ -7,23 +7,23 @@ module ActiveRecord
|
|
|
7
7
|
def translate_exception(exception, message:, sql:, binds:)
|
|
8
8
|
cause = exception.cause
|
|
9
9
|
case cause
|
|
10
|
-
when ::Turso::
|
|
10
|
+
when ::Turso::ConstraintException
|
|
11
11
|
translate_constraint_error(message, sql, binds)
|
|
12
|
-
when ::Turso::
|
|
12
|
+
when ::Turso::NotADatabaseException
|
|
13
13
|
ActiveRecord::NoDatabaseError.new(message, sql: sql, binds: binds)
|
|
14
|
-
when ::Turso::
|
|
14
|
+
when ::Turso::BusySnapshotException
|
|
15
15
|
ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
|
|
16
|
-
when ::Turso::
|
|
16
|
+
when ::Turso::BusyException
|
|
17
17
|
ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
|
|
18
|
-
when ::Turso::
|
|
18
|
+
when ::Turso::ReadonlyException
|
|
19
19
|
ActiveRecord::ReadOnlyRecord.new(message, sql: sql, binds: binds)
|
|
20
|
-
when ::Turso::
|
|
20
|
+
when ::Turso::IoException, ::Turso::CorruptException
|
|
21
21
|
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
22
|
-
when ::Turso::
|
|
22
|
+
when ::Turso::DatabaseFullException
|
|
23
23
|
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
24
|
-
when ::Turso::
|
|
24
|
+
when ::Turso::InterruptException
|
|
25
25
|
ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
|
|
26
|
-
when ::Turso::
|
|
26
|
+
when ::Turso::MisuseException
|
|
27
27
|
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
28
28
|
else
|
|
29
29
|
super
|
|
@@ -34,7 +34,7 @@ module ActiveRecord
|
|
|
34
34
|
|
|
35
35
|
def translate_constraint_error(message, sql, binds)
|
|
36
36
|
case message
|
|
37
|
-
when /foreign key constraint/i
|
|
37
|
+
when /foreign key constraint|foreign key mismatch/i
|
|
38
38
|
ActiveRecord::InvalidForeignKey.new(message, sql: sql, binds: binds)
|
|
39
39
|
when /unique constraint|primary key/i
|
|
40
40
|
ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
|
|
@@ -34,6 +34,10 @@ module ActiveRecord
|
|
|
34
34
|
|
|
35
35
|
def transaction(requires_new: nil, isolation: nil, joinable: true, **options, &block)
|
|
36
36
|
if options.delete(:concurrent)
|
|
37
|
+
ActiveRecord::Base.logger&.warn(
|
|
38
|
+
"transaction(concurrent: true) is experimental and may break ActiveRecord model persistence. " \
|
|
39
|
+
"See adapter documentation."
|
|
40
|
+
)
|
|
37
41
|
transaction_with_mvcc(options, &block)
|
|
38
42
|
else
|
|
39
43
|
super
|
|
@@ -85,8 +89,7 @@ module ActiveRecord
|
|
|
85
89
|
|
|
86
90
|
def concurrent_conflict?(exception)
|
|
87
91
|
cause = exception.cause
|
|
88
|
-
conflict_classes = [::Turso::
|
|
89
|
-
conflict_classes << ::Turso::WriteWriteConflict if defined?(::Turso::WriteWriteConflict)
|
|
92
|
+
conflict_classes = [::Turso::BusySnapshotException, ::Turso::BusyException]
|
|
90
93
|
|
|
91
94
|
conflict_classes.any? { |klass| cause.is_a?(klass) } ||
|
|
92
95
|
/snapshot conflict|busy snapshot|database is locked|cannot start a transaction within a transaction/i.match?(exception.message)
|
|
@@ -25,6 +25,10 @@ module ActiveRecord
|
|
|
25
25
|
false
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
def sqlite_version
|
|
29
|
+
@sqlite_version ||= Gem::Version.new(query_value("SELECT sqlite_version()"))
|
|
30
|
+
end
|
|
31
|
+
|
|
28
32
|
def supports_transaction_isolation?
|
|
29
33
|
false
|
|
30
34
|
end
|
|
@@ -37,10 +41,16 @@ module ActiveRecord
|
|
|
37
41
|
true
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
def quote_string(s)
|
|
45
|
+
s.gsub("'", "''")
|
|
46
|
+
end
|
|
47
|
+
|
|
40
48
|
def explain(arel, binds = [], _options = [])
|
|
41
49
|
sql = "EXPLAIN QUERY PLAN " + to_sql(arel, binds)
|
|
42
50
|
result = exec_query(sql, "EXPLAIN", binds)
|
|
43
|
-
|
|
51
|
+
result.rows.map do |row|
|
|
52
|
+
row.join(" | ")
|
|
53
|
+
end.join("\n")
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
def build_statement_pool
|
data/lib/turso/ar/connection.rb
CHANGED
|
@@ -19,11 +19,11 @@ module Turso
|
|
|
19
19
|
query_timeout: config[:query_timeout] || DEFAULT_QUERY_TIMEOUT_MS
|
|
20
20
|
}
|
|
21
21
|
db_opts[:experimental_features] = config[:experimental_features] if config[:experimental_features]
|
|
22
|
-
@db = ::Turso::
|
|
22
|
+
@db = ::Turso::Database.new(config[:database].to_s, **db_opts)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def last_insert_rowid
|
|
26
|
-
query("SELECT last_insert_rowid()").first&.
|
|
26
|
+
query("SELECT last_insert_rowid()").first&.to_a&.first.to_i
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def raw_connection
|
|
@@ -39,12 +39,12 @@ module Turso
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def execute(sql, binds = [])
|
|
42
|
-
@db.execute(sql, normalize_binds(binds))
|
|
42
|
+
@db.execute(sql, *normalize_binds(binds))
|
|
43
43
|
nil
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def query(sql, params = [])
|
|
47
|
-
@db.query(sql, normalize_binds(params))
|
|
47
|
+
@db.query(sql, *normalize_binds(params))
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def execute_batch(sql)
|
|
@@ -61,10 +61,6 @@ module Turso
|
|
|
61
61
|
@db.busy_timeout = ms.to_i
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def query_timeout=(ms)
|
|
65
|
-
@db.query_timeout = ms.to_i
|
|
66
|
-
end
|
|
67
|
-
|
|
68
64
|
def interrupt
|
|
69
65
|
@db.interrupt
|
|
70
66
|
end
|
|
@@ -127,6 +123,10 @@ module Turso
|
|
|
127
123
|
next
|
|
128
124
|
end
|
|
129
125
|
current << char
|
|
126
|
+
when "#"
|
|
127
|
+
in_line_comment = true
|
|
128
|
+
i += 1
|
|
129
|
+
next
|
|
130
130
|
when "/"
|
|
131
131
|
if next_char == "*"
|
|
132
132
|
in_block_comment = true
|