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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69e6be67a21fd809792bf867f81f6205bb06a6cc457aa05c2b80080cf272fd9b
4
- data.tar.gz: d1a18f2e763cb6b5781521f1e8bd12f8a7159d3350c2fe5e91fe02ee00a50f1f
3
+ metadata.gz: 04f8ecc726fa450fadcf8b819a04663b3035bed1c1b5f592d362ec07664d2f14
4
+ data.tar.gz: 4ce85117e6f562ead6d2defeaaa5c70fccc2c62492d9916c798e56bcf32acfca
5
5
  SHA512:
6
- metadata.gz: defa80378418aabb5b0b619392e180102f633ce7f8f6a13d6778f34a47c47f14c2739dc5248aaf0011e8f40788002ae42fea373789e3fc8b5e6e1ea5ebac8b35
7
- data.tar.gz: 573a7f4f59c98621b9c4f95af1daa2a8fe133b3e487ef83c7d73af51c8c65655cbff080ef2d3813d7e64868273b186f121d69653499bee67f1180463fc946f9a
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.0
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
- ### MVCC / BEGIN CONCURRENT
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. The error is retried a few times, then raised as `ActiveRecord::StatementInvalid`.
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. Some SQLite-specific features are unsupported or conservatively flagged
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
- ### 7. `execute_batch` in the underlying bindings is a Ruby-side fallback
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
- query_timeout = @config[:query_timeout] || 30_000
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
- with_raw_connection do |conn|
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.bind_positional(type_casted_binds) unless type_casted_binds.empty?
51
+ stmt.bind(*type_casted_binds) unless type_casted_binds.empty?
61
52
 
62
53
  begin
63
- if write_query?(sql)
64
- affected_rows = stmt.execute
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
- columns = (0...stmt.column_count).map { |i| stmt.column_name(i) }
71
- rows = []
72
- while stmt.step == 1
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.finalize unless prepare
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
- if old_defer_foreign_keys
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
- count = stmt.column_count
108
- count.times do |i|
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::ConstraintError
10
+ when ::Turso::ConstraintException
11
11
  translate_constraint_error(message, sql, binds)
12
- when ::Turso::NotADatabaseError
12
+ when ::Turso::NotADatabaseException
13
13
  ActiveRecord::NoDatabaseError.new(message, sql: sql, binds: binds)
14
- when ::Turso::BusySnapshotError
14
+ when ::Turso::BusySnapshotException
15
15
  ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
16
- when ::Turso::BusyError
16
+ when ::Turso::BusyException
17
17
  ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
18
- when ::Turso::ReadonlyError
18
+ when ::Turso::ReadonlyException
19
19
  ActiveRecord::ReadOnlyRecord.new(message, sql: sql, binds: binds)
20
- when ::Turso::IoError, ::Turso::CorruptError
20
+ when ::Turso::IoException, ::Turso::CorruptException
21
21
  ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
22
- when ::Turso::DatabaseFullError
22
+ when ::Turso::DatabaseFullException
23
23
  ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
24
- when ::Turso::InterruptError
24
+ when ::Turso::InterruptException
25
25
  ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
26
- when ::Turso::MisuseError
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)
@@ -11,6 +11,7 @@ module ActiveRecord
11
11
  AND name NOT LIKE 'sqlite_%'
12
12
  AND name NOT LIKE 'fts_dir_%'
13
13
  AND name NOT LIKE 'sqlite_fts_%'
14
+ AND name NOT LIKE '__turso_internal_%'
14
15
  SQL
15
16
  end
16
17
 
@@ -9,7 +9,7 @@ module ActiveRecord
9
9
  private
10
10
 
11
11
  def dealloc(stmt)
12
- stmt.finalize
12
+ stmt.close
13
13
  rescue ::Turso::Error
14
14
  end
15
15
  end
@@ -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::BusySnapshotError, ::Turso::BusyError]
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
- SQLite3::ExplainPrettyPrinter.new.pp(result)
51
+ result.rows.map do |row|
52
+ row.join(" | ")
53
+ end.join("\n")
44
54
  end
45
55
 
46
56
  def build_statement_pool
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordTurso
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -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::DB.new(config[:database].to_s, **db_opts)
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&.values&.first.to_i
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
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo