activerecord-turso 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04f8ecc726fa450fadcf8b819a04663b3035bed1c1b5f592d362ec07664d2f14
4
- data.tar.gz: 4ce85117e6f562ead6d2defeaaa5c70fccc2c62492d9916c798e56bcf32acfca
3
+ metadata.gz: cfdd23462f8313c9a19d64bab33314e3d5a4b7532249695f01811ebcf3fcccbd
4
+ data.tar.gz: 74e33f479629655b50621e300dc7a1d38b2d94d95edd2da9b57b3d18c5799dbe
5
5
  SHA512:
6
- metadata.gz: 6d18e249a927642d705959341439129523cb3f2763ab699a8c2dfbc1f38d8f52b3b831a96f140a95518f3e932a9da348184af605525d354a54893419f332c0aa
7
- data.tar.gz: 8b1cec4617634414e11928453aeed21e2c382fe4a36357ca435b601f337f8b9ca3fb732f035db02ebd8a0fe8f7dfc35db961c384faffd2cf8937ea612ec6363e
6
+ metadata.gz: 9dee234b1fa1abd37883de7c10b876cad339858a41643000eee969348ae0ad1da8458a8b546145aa1c7e38648e5e7da3734d35fa484b08c0d87260dc170f3fe5
7
+ data.tar.gz: 314bda7c65ccc415af50204690e098859517419f8f7ba035852a6230c0654c40f94a82ba3bd22ba54a829920cfb7f9159fbf1acc0de8e04816641669b6789938
data/README.md CHANGED
@@ -4,12 +4,12 @@ ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible db).
4
4
 
5
5
  ## Status
6
6
 
7
- This adapter is under **active** development. It can run basic ActiveRecord operations against Turso today, but several production features are still being hardened. See [Limitations and Risks](#limitations-and-risks) before using it in production.
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
 
11
11
  - Ruby >= 3.2
12
- - ActiveRecord >= 8.0, < 8.2
12
+ - ActiveRecord ~> 8.1.0
13
13
  - The `turso` Ruby gem
14
14
 
15
15
  ## Runtime dependencies
@@ -51,7 +51,9 @@ end
51
51
  Post.create!(title: "Hello", body: "World", published: true)
52
52
  ```
53
53
 
54
- ## Recommended production configuration
54
+ ## Configuration
55
+
56
+ ### Recommended production configuration
55
57
 
56
58
  ```yaml
57
59
  production:
@@ -65,6 +67,23 @@ production:
65
67
  experimental_features: "index_method"
66
68
  ```
67
69
 
70
+ ### Connection options
71
+
72
+ | Option | Description | Default |
73
+ |---|---|---|
74
+ | `database` | Path to the SQLite database file | Required |
75
+ | `journal_mode` | Journal mode (`wal`, `mvcc`, `delete`, etc.) | WAL |
76
+ | `pool` | Connection pool size | 5 |
77
+ | `timeout` | Busy timeout in milliseconds | 5000 |
78
+ | `busy_timeout` | Busy timeout in milliseconds (overrides `timeout`) | 5000 |
79
+ | `query_timeout` | Query timeout in milliseconds | 30000 |
80
+ | `experimental_features` | Comma-separated experimental features (`index_method`, `generated_columns`) | None |
81
+ | `statement_limit` | Maximum number of cached prepared statements | 1000 |
82
+
83
+ ### `query_timeout` and `interrupt`
84
+
85
+ The adapter exposes `query_timeout=` to set a maximum execution time for queries. When a query exceeds this timeout, the Turso engine interrupts the operation and raises `ActiveRecord::QueryCanceled`. Use `interrupt` to cancel a running query from another thread.
86
+
68
87
  ### MVCC / BEGIN CONCURRENT (experimental)
69
88
 
70
89
  ⚠️ **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)`.
@@ -95,6 +114,7 @@ development:
95
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.
96
115
  - `lock!`, `with_lock`, and `lock_version` are not meaningful under MVCC. Do not use them inside concurrent transactions.
97
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.
98
118
 
99
119
  ### Full-text search (Tantivy)
100
120
 
@@ -168,9 +188,9 @@ The following limitations apply to the current implementation. Read this section
168
188
 
169
189
  The adapter builds column type maps from `column_decltype` metadata. This works for most column definitions but may not capture type information for computed expressions or subquery columns.
170
190
 
171
- ### 2. Batch SQL execution uses a simple string splitter
191
+ ### 2. Batch SQL execution uses the Turso native batch API
172
192
 
173
- The adapter's batch execution path splits multi-statement SQL on semicolons. This means SQL containing semicolons inside string literals, triggers, or stored expressions may be split incorrectly. Avoid relying on multi-statement strings other than simple schema dumps.
193
+ The adapter's batch execution path uses the Turso native batch API (`execute_batch`), which correctly handles semicolons inside string literals, triggers, and stored expressions.
174
194
 
175
195
  ### 3. MVCC requires opt-in and has ActiveRecord compatibility caveats
176
196
 
@@ -187,9 +207,9 @@ Only use `transaction(concurrent: true)` after testing it under your app's concu
187
207
 
188
208
  The adapter uses a bounded statement pool with a default limit inherited from Rails (typically `1000`). Statements are evicted with an LRU policy and finalized against the underlying Turso connection. Set `statement_limit` in `database.yml` to tune the pool size.
189
209
 
190
- ### 5. ActiveRecord 8.0 support is CI-tested, not locally tested
210
+ ### 5. Only ActiveRecord 8.1 is supported
191
211
 
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.
212
+ The adapter is pinned to ActiveRecord `~> 8.1.0`.
193
213
 
194
214
  ### 6. INSERT RETURNING is disabled (not supported by Turso)
195
215
 
@@ -200,9 +220,9 @@ The underlying Turso SQLite build does not support `INSERT ... RETURNING` syntax
200
220
  - 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.
201
221
  - `insert_on_conflict` is enabled only when the reported SQLite version is `>= 3.24.0`.
202
222
 
203
- ### 8. `execute_batch` in the underlying bindings is a Ruby-side fallback
223
+ ### 8. `execute_batch` uses the Turso native batch API
204
224
 
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.
225
+ The `turso` gem's `execute_batch` uses the Turso native batch API (`prepare_first`/`execute` loop), correctly handling edge cases like semicolons in string literals and triggers.
206
226
 
207
227
  ## Development
208
228
 
@@ -65,7 +65,8 @@ module ActiveRecord
65
65
  timeout = @config[:busy_timeout] || @config[:timeout] || 5000
66
66
  @raw_connection.busy_timeout = timeout
67
67
 
68
-
68
+ query_timeout = @config[:query_timeout] || 30_000
69
+ @raw_connection.query_timeout = query_timeout
69
70
  end
70
71
  end
71
72
  end
@@ -5,7 +5,7 @@ module ActiveRecord
5
5
  class TursoAdapter < SQLite3Adapter
6
6
  module ErrorTranslation
7
7
  def translate_exception(exception, message:, sql:, binds:)
8
- cause = exception.cause
8
+ cause = exception.cause || exception
9
9
  case cause
10
10
  when ::Turso::ConstraintException
11
11
  translate_constraint_error(message, sql, binds)
@@ -14,7 +14,7 @@ module ActiveRecord
14
14
  when ::Turso::BusySnapshotException
15
15
  ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
16
16
  when ::Turso::BusyException
17
- ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
17
+ ActiveRecordTurso::BusyError.new(message, sql: sql, binds: binds)
18
18
  when ::Turso::ReadonlyException
19
19
  ActiveRecord::ReadOnlyRecord.new(message, sql: sql, binds: binds)
20
20
  when ::Turso::IoException, ::Turso::CorruptException
@@ -38,6 +38,8 @@ module ActiveRecord
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)
41
+ when /NOT NULL|cannot be NULL/i
42
+ ActiveRecord::NotNullViolation.new(message, sql: sql, binds: binds)
41
43
  else
42
44
  ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
43
45
  end
@@ -0,0 +1,124 @@
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 sql IS NOT NULL
69
+ ORDER BY name
70
+ SQL
71
+
72
+ indexes.each do |index|
73
+ sql = connection.query_value(<<~SQL, "SCHEMA")
74
+ SELECT sql FROM sqlite_master WHERE name = #{connection.quote(index)}
75
+ SQL
76
+ io.puts("#{sql};") if sql
77
+ end
78
+
79
+ views = connection.query_values(<<~SQL, "SCHEMA")
80
+ SELECT name FROM sqlite_master
81
+ WHERE type = 'view'
82
+ AND name NOT LIKE 'sqlite_%'
83
+ ORDER BY name
84
+ SQL
85
+
86
+ views.each do |view|
87
+ sql = connection.query_value(<<~SQL, "SCHEMA")
88
+ SELECT sql FROM sqlite_master WHERE name = #{connection.quote(view)}
89
+ SQL
90
+ io.puts("#{sql};") if sql
91
+ end
92
+
93
+ io.puts("PRAGMA foreign_keys = ON;")
94
+ io.close
95
+ end
96
+
97
+ def structure_load(filename, extra_flags)
98
+ establish_connection(configuration)
99
+ sql = File.read(filename)
100
+ ActiveRecord::Base.connection.raw_connection.execute_batch(sql)
101
+ end
102
+
103
+ private
104
+
105
+ def configuration
106
+ @configuration
107
+ end
108
+
109
+ def connection
110
+ ActiveRecord::Base.connection
111
+ end
112
+
113
+ def establish_connection(config)
114
+ @configuration = config
115
+ ActiveRecord::Base.establish_connection(config)
116
+ ActiveRecord::Base.connection.connect!
117
+ end
118
+
119
+ def disconnect!
120
+ ActiveRecord::Base.connection_pool.disconnect!
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordTurso
4
+ class Error < ActiveRecord::StatementInvalid; end
5
+ class BusyError < Error; end
6
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordTurso
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require "active_record"
4
4
  require "turso"
5
5
 
6
+ require_relative "activerecord-turso/error"
6
7
  require_relative "turso/ar/connection"
7
8
  require_relative "active_record/connection_adapters/turso_adapter"
8
9
 
@@ -11,3 +12,8 @@ ActiveRecord::ConnectionAdapters.register(
11
12
  "ActiveRecord::ConnectionAdapters::TursoAdapter",
12
13
  "active_record/connection_adapters/turso_adapter"
13
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")
@@ -7,7 +7,9 @@ module Turso
7
7
  class Connection
8
8
  extend Forwardable
9
9
 
10
- def_delegators :@db, :close, :closed?, :changes, :total_changes
10
+ def_delegators :@db, :close, :closed?, :changes, :total_changes,
11
+ :last_insert_rowid, :prepare, :interrupt, :busy_timeout=,
12
+ :query_timeout, :query_timeout=
11
13
 
12
14
  DEFAULT_BUSY_TIMEOUT_MS = 5000
13
15
  DEFAULT_QUERY_TIMEOUT_MS = 30_000
@@ -18,12 +20,14 @@ module Turso
18
20
  busy_timeout: config[:busy_timeout] || config[:timeout] || DEFAULT_BUSY_TIMEOUT_MS,
19
21
  query_timeout: config[:query_timeout] || DEFAULT_QUERY_TIMEOUT_MS
20
22
  }
21
- db_opts[:experimental_features] = config[:experimental_features] if config[:experimental_features]
22
- @db = ::Turso::Database.new(config[:database].to_s, **db_opts)
23
- end
24
-
25
- def last_insert_rowid
26
- query("SELECT last_insert_rowid()").first&.to_a&.first.to_i
23
+ if config[:experimental_features]
24
+ features = Array(config[:experimental_features]).map(&:to_s).reject(&:empty?)
25
+ db_opts[:experimental_features] = features.join(",") unless features.empty?
26
+ end
27
+ database = ::Turso::Database.new(config[:database].to_s, **db_opts)
28
+ @db = database.connection
29
+ @db.busy_timeout = db_opts[:busy_timeout]
30
+ @db.query_timeout = db_opts[:query_timeout]
27
31
  end
28
32
 
29
33
  def raw_connection
@@ -48,107 +52,11 @@ module Turso
48
52
  end
49
53
 
50
54
  def execute_batch(sql)
51
- split_batch(sql).each do |stmt|
52
- @db.execute(stmt)
53
- end
54
- end
55
-
56
- def prepare(sql)
57
- @db.prepare(sql)
58
- end
59
-
60
- def busy_timeout=(ms)
61
- @db.busy_timeout = ms.to_i
62
- end
63
-
64
- def interrupt
65
- @db.interrupt
55
+ @db.execute_batch(sql)
66
56
  end
67
57
 
68
58
  private
69
59
 
70
- def split_batch(sql)
71
- statements = []
72
- current = +""
73
- in_string = false
74
- in_line_comment = false
75
- in_block_comment = false
76
- i = 0
77
- while i < sql.length
78
- char = sql[i]
79
- next_char = sql[i + 1]
80
-
81
- if in_line_comment
82
- if char == "\n"
83
- in_line_comment = false
84
- end
85
- i += 1
86
- next
87
- end
88
-
89
- if in_block_comment
90
- if char == "*" && next_char == "/"
91
- in_block_comment = false
92
- i += 2
93
- else
94
- i += 1
95
- end
96
- next
97
- end
98
-
99
- if in_string
100
- if char == "'" && next_char == "'"
101
- current << char << next_char
102
- i += 2
103
- next
104
- elsif char == "'"
105
- in_string = false
106
- current << char
107
- i += 1
108
- next
109
- end
110
- current << char
111
- i += 1
112
- next
113
- end
114
-
115
- case char
116
- when "'"
117
- in_string = true
118
- current << char
119
- when "-"
120
- if next_char == "-"
121
- in_line_comment = true
122
- i += 2
123
- next
124
- end
125
- current << char
126
- when "#"
127
- in_line_comment = true
128
- i += 1
129
- next
130
- when "/"
131
- if next_char == "*"
132
- in_block_comment = true
133
- i += 2
134
- next
135
- end
136
- current << char
137
- when ";"
138
- stmt = current.strip
139
- statements << stmt unless stmt.empty?
140
- current = +""
141
- else
142
- current << char
143
- end
144
- i += 1
145
- end
146
-
147
- stmt = current.strip
148
- statements << stmt unless stmt.empty?
149
- statements
150
- end
151
-
152
60
  def normalize_binds(binds)
153
61
  binds.map do |value|
154
62
  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.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -13,22 +13,16 @@ dependencies:
13
13
  name: activerecord
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '8.0'
19
- - - "<"
16
+ - - "~>"
20
17
  - !ruby/object:Gem::Version
21
- version: '8.2'
18
+ version: 8.1.0
22
19
  type: :runtime
23
20
  prerelease: false
24
21
  version_requirements: !ruby/object:Gem::Requirement
25
22
  requirements:
26
- - - ">="
27
- - !ruby/object:Gem::Version
28
- version: '8.0'
29
- - - "<"
23
+ - - "~>"
30
24
  - !ruby/object:Gem::Version
31
- version: '8.2'
25
+ version: 8.1.0
32
26
  - !ruby/object:Gem::Dependency
33
27
  name: turso
34
28
  requirement: !ruby/object:Gem::Requirement
@@ -69,7 +63,9 @@ files:
69
63
  - lib/active_record/connection_adapters/turso_adapter/schema_statements.rb
70
64
  - lib/active_record/connection_adapters/turso_adapter/statement_pool.rb
71
65
  - lib/active_record/connection_adapters/turso_adapter/transaction_management.rb
66
+ - lib/active_record/tasks/turso_database_tasks.rb
72
67
  - lib/activerecord-turso.rb
68
+ - lib/activerecord-turso/error.rb
73
69
  - lib/activerecord-turso/version.rb
74
70
  - lib/turso/ar/connection.rb
75
71
  licenses:
@@ -91,5 +87,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
91
87
  requirements: []
92
88
  rubygems_version: 4.0.15
93
89
  specification_version: 4
94
- summary: ActiveRecord adapter for Turso
90
+ summary: Embedded ActiveRecord adapter for Turso (SQLite-compatible)
95
91
  test_files: []