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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8e00bcf26be0b39f02a05bd0f4850db41d277f7d72bdb15013c218f032d93eb
4
- data.tar.gz: 9e9ccad1e346648fef21eb2d0a3aceafa7afada0bc67348a0ed912b723e6cca5
3
+ metadata.gz: 8dc77c288fa28d1c9872459245f9afe593a510dac77d5e0630bdb58d7bab4b90
4
+ data.tar.gz: 192ddeb9179b451e2844ce533f1bbaf7a96bae2c493a1b0a322398401dae838a
5
5
  SHA512:
6
- metadata.gz: c16dce07f7dbcf2bd60d07e58b10d6960c7d4a2945f88deda0082c532b0e1e42cc58213582acd2548c0c19d72a13ef30e5688503ba8ab9a00b7fa99f882048e8
7
- data.tar.gz: 22f295b0eda449f11b5f31c63b16718e5f609452904b0c9c5b534117fe4145324460d3ed56f7ac7365f38345ac165eb8969a3e01bb5c21d59c2e9f3bef2f4f4d
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
- return false unless @raw_connection
21
- !@raw_connection.closed?
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
- cause = exception.cause
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") rescue nil
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordTurso
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -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
@@ -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
- db_opts[:experimental_features] = config[:experimental_features] if config[:experimental_features]
23
- database = ::Turso::Database.new(config[:database].to_s, **db_opts)
24
- @db = database.connection
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.2.2
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.15
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: []