activerecord-libsql 0.1.1 → 0.1.3

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: 7bd70521b40c17e3e116bdb9d70dec71bfa97b2ffbc651555b3f2e9c284b90e2
4
- data.tar.gz: 5b33ef7cae18df4ffbe9c0805af0b82b1a4291a842a9cdef34ffabe43eddb849
3
+ metadata.gz: bc66869ba4dd6395bcac2e61abbef9e58dc7bb87f7fc23029aa19f2e5a2419b4
4
+ data.tar.gz: 5a4feeb7234a157cec8b3f8f2be015d24f5163581e42dc5c501b844830e4b704
5
5
  SHA512:
6
- metadata.gz: a743362f8761cd6f69d157bfc266e9874178a3def3592b19f0cdc3391e0ea4fedf7cb7dfd2fb9cf394fffff6408ab313e2044dc934e98c269c7804f8dbd719a4
7
- data.tar.gz: f15229b17113d1defa3fda201eb34c50ba5bb89b535198fb2f246dfa9f26d64baa36518c28c696c69d3341532336488c94d619abe5d7b5e547c41bbbe52a8324
6
+ metadata.gz: 8aea75dac63b1dabf1775d84a99a7a7c80dde393436b72e9e76ae071889fc91f232d11cbcd272eac60c7ae734df2a6a805a3dc35ec976a58b191734b913c1a8e
7
+ data.tar.gz: f18071f7a38d32d9ca84ae82ae8e529c5c7e2237b4db87bae0f12eb591f30fbe97b5df599470c17a2dc59676335610cafe78f86ffc263d112a5bf38fd706459d
@@ -11,8 +11,9 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = 'ActiveRecord adapter for Turso (libSQL) database'
12
12
  spec.description = <<~DESC
13
13
  An ActiveRecord adapter for Turso, the edge SQLite database powered by libSQL.
14
- Uses a native Rust extension (via magnus) to connect directly to Turso via the
15
- libSQL remote protocol, without requiring any external HTTP client.
14
+ Connects to Turso Cloud via the Hrana v2 HTTP protocol using Ruby's built-in
15
+ Net::HTTP, making it fork-safe and dependency-free. Supports Embedded Replica
16
+ mode via the sqlite3 gem for local read performance.
16
17
  DESC
17
18
  spec.homepage = 'https://github.com/aileron-inc/activerecord-libsql'
18
19
  spec.license = 'MIT'
@@ -23,17 +24,14 @@ Gem::Specification.new do |spec|
23
24
 
24
25
  spec.files = Dir[
25
26
  'lib/**/*.rb',
26
- 'ext/**/*.{rs,toml,rb}',
27
27
  '*.md',
28
- '*.gemspec',
29
- 'Cargo.{toml,lock}'
28
+ '*.gemspec'
30
29
  ]
31
30
 
32
31
  spec.require_paths = ['lib']
33
- spec.extensions = ['ext/turso_libsql/extconf.rb']
34
32
 
35
33
  spec.add_dependency 'activerecord', '>= 7.0'
36
- spec.add_dependency 'rb_sys', '~> 0.9'
34
+ spec.add_dependency 'sqlite3', '>= 1.4'
37
35
 
38
36
  spec.add_development_dependency 'rake', '~> 13.0'
39
37
  spec.add_development_dependency 'rake-compiler', '~> 1.2'
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'active_record'
4
4
  require 'active_record/connection_adapters/abstract_adapter'
5
- require 'turso_libsql/turso_libsql'
5
+ require 'turso_libsql'
6
6
 
7
7
  # AR 7.2+ のアダプター登録 API
8
8
  ActiveSupport.on_load(:active_record) do
@@ -113,17 +113,40 @@ module ActiveRecord
113
113
  false
114
114
  end
115
115
 
116
- def reconnect!
117
- @raw_database, @raw_connection = build_libsql_connection
116
+ def disconnect!
117
+ @raw_connection = nil
118
+ @raw_database = nil
118
119
  super
119
120
  end
120
121
 
121
- def disconnect!
122
+ # fork 後の子プロセスで呼ばれる。親プロセスの Rust オブジェクトを
123
+ # 子プロセスから触ると SEGV するため、参照を即座に破棄する。
124
+ # さらに tokio ランタイムも fork で壊れるため reinitialize_runtime! で作り直す。
125
+ # AR の ConnectionPool が fork 後に各コネクションに対して呼ぶ。
126
+ def discard!
122
127
  @raw_connection = nil
123
128
  @raw_database = nil
129
+ TursoLibsql.reinitialize_runtime!
124
130
  super
125
131
  end
126
132
 
133
+ private
134
+
135
+ # AR 8 の reconnect! が内部で呼ぶ private メソッド。
136
+ # 既存接続を破棄して新しい接続を確立する。
137
+ def reconnect
138
+ @raw_connection = nil
139
+ @raw_database = nil
140
+ @raw_database, @raw_connection = build_libsql_connection
141
+ end
142
+
143
+ # AR 8 の connect! が内部で呼ぶ private メソッド(一部のパスで使われる)。
144
+ def connect
145
+ @raw_database, @raw_connection = build_libsql_connection
146
+ end
147
+
148
+ public
149
+
127
150
  # Embedded Replica モードでリモートから最新フレームを手動同期する。
128
151
  # Remote モードでは何もしない(no-op)。
129
152
  def sync
@@ -154,8 +177,9 @@ module ActiveRecord
154
177
  build_result(rows)
155
178
  else
156
179
  affected = raw_connection.execute(expanded_sql)
157
- notification_payload[:row_count] = affected if notification_payload
158
- ActiveRecord::Result.empty(affected_rows: affected.to_i)
180
+ @last_affected_rows = affected.to_i
181
+ notification_payload[:row_count] = @last_affected_rows if notification_payload
182
+ ActiveRecord::Result.empty
159
183
  end
160
184
  rescue RuntimeError => e
161
185
  raise translate_exception(e, message: e.message, sql: expanded_sql, binds: [])
@@ -166,8 +190,8 @@ module ActiveRecord
166
190
  raw_result
167
191
  end
168
192
 
169
- def affected_rows(raw_result)
170
- raw_result.length
193
+ def affected_rows(_raw_result)
194
+ @last_affected_rows || 0
171
195
  end
172
196
 
173
197
  # -----------------------------------------------------------------------
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Libsql
5
- VERSION = '0.1.1'
5
+ VERSION = '0.1.3'
6
6
  end
7
7
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module TursoLibsql
8
+ # Hrana v2 HTTP プロトコルを使ったリモート接続
9
+ # Net::HTTP を使用するため fork 後も安全
10
+ class Connection
11
+ def initialize(url, token)
12
+ @hrana_url = hrana_url(url)
13
+ @token = token
14
+ @baton = nil
15
+ @last_insert_rowid = 0
16
+ @last_affected_rows = 0
17
+ end
18
+
19
+ # SQL を実行し、影響を受けた行数を返す(INSERT/UPDATE/DELETE 用)
20
+ def execute(sql)
21
+ execute_sql(sql, [])
22
+ end
23
+
24
+ # SQL を実行し、結果を Array of Hash で返す(SELECT 用)
25
+ def query(sql)
26
+ query_sql(sql, [])
27
+ end
28
+
29
+ # プリペアドステートメントで SQL を実行(パラメータ付き)
30
+ def execute_with_params(sql, params)
31
+ json_params = params.map { |p| { 'type' => 'text', 'value' => p.to_s } }
32
+ execute_sql(sql, json_params)
33
+ end
34
+
35
+ # トランザクションを開始する
36
+ def begin_transaction
37
+ requests = [{ 'type' => 'execute', 'stmt' => { 'sql' => 'BEGIN' } }]
38
+ resp = hrana_pipeline(nil, requests)
39
+ @baton = resp['baton']
40
+ check_errors(resp)
41
+ end
42
+
43
+ # トランザクションをコミットする
44
+ def commit_transaction
45
+ requests = [
46
+ { 'type' => 'execute', 'stmt' => { 'sql' => 'COMMIT' } },
47
+ { 'type' => 'close' }
48
+ ]
49
+ resp = hrana_pipeline(@baton, requests)
50
+ @baton = nil
51
+ check_errors(resp)
52
+ end
53
+
54
+ # トランザクションをロールバックする
55
+ def rollback_transaction
56
+ requests = [
57
+ { 'type' => 'execute', 'stmt' => { 'sql' => 'ROLLBACK' } },
58
+ { 'type' => 'close' }
59
+ ]
60
+ # baton が無効になっている場合(サーバー側でエラー後に破棄された場合)は
61
+ # baton なしで ROLLBACK を試みる。失敗しても無視する(接続は既に破棄済み)
62
+ baton = @baton
63
+ @baton = nil
64
+ begin
65
+ resp = hrana_pipeline(baton, requests)
66
+ check_errors(resp)
67
+ rescue StandardError
68
+ # ROLLBACK 失敗は無視(接続が既に破棄されている場合)
69
+ end
70
+ end
71
+
72
+ # 最後に挿入した行の rowid を返す
73
+ attr_reader :last_insert_rowid
74
+
75
+ private
76
+
77
+ def execute_sql(sql, params)
78
+ stmt = build_stmt(sql, params)
79
+ requests = if @baton
80
+ [stmt]
81
+ else
82
+ [stmt, { 'type' => 'close' }]
83
+ end
84
+
85
+ resp = hrana_pipeline(@baton, requests)
86
+ check_errors(resp)
87
+
88
+ @baton = resp['baton'] if @baton
89
+ if (results = resp['results'])&.first
90
+ @last_affected_rows = results.first.dig('response', 'result', 'affected_row_count').to_i
91
+ rowid_str = results.first.dig('response', 'result', 'last_insert_rowid')
92
+ @last_insert_rowid = rowid_str.to_i
93
+ end
94
+
95
+ @last_affected_rows
96
+ end
97
+
98
+ def query_sql(sql, params)
99
+ stmt = build_stmt(sql, params)
100
+ requests = if @baton
101
+ [stmt]
102
+ else
103
+ [stmt, { 'type' => 'close' }]
104
+ end
105
+
106
+ resp = hrana_pipeline(@baton, requests)
107
+ check_errors(resp)
108
+
109
+ @baton = resp['baton'] if @baton
110
+
111
+ result = resp.dig('results', 0, 'response', 'result')
112
+ return [] unless result
113
+
114
+ cols = result['cols']&.map { |c| c['name'] } || []
115
+ rows = result['rows'] || []
116
+
117
+ rows.map do |row|
118
+ record = {}
119
+ cols.each_with_index do |col, i|
120
+ record[col] = hrana_value_to_ruby(row[i])
121
+ end
122
+ record
123
+ end
124
+ end
125
+
126
+ def build_stmt(sql, params)
127
+ stmt = { 'sql' => sql }
128
+ stmt['args'] = params if params.any?
129
+ { 'type' => 'execute', 'stmt' => stmt }
130
+ end
131
+
132
+ def hrana_pipeline(baton, requests)
133
+ body = { 'requests' => requests }
134
+ body['baton'] = baton if baton
135
+
136
+ uri = URI.parse(@hrana_url)
137
+ http = Net::HTTP.new(uri.host, uri.port)
138
+ http.use_ssl = (uri.scheme == 'https')
139
+ http.open_timeout = 10
140
+ http.read_timeout = 30
141
+
142
+ request = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.path)
143
+ request['Authorization'] = "Bearer #{@token}"
144
+ request['Content-Type'] = 'application/json'
145
+ request.body = JSON.generate(body)
146
+
147
+ response = http.request(request)
148
+
149
+ raise "HTTP error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
150
+
151
+ JSON.parse(response.body)
152
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
153
+ SocketError, Socket::ResolutionError => e
154
+ raise "HTTP request failed: #{@hrana_url}: #{e.message}"
155
+ end
156
+
157
+ def check_errors(resp)
158
+ return unless (results = resp['results'])
159
+
160
+ results.each do |r|
161
+ next unless r['type'] == 'error'
162
+
163
+ msg = r.dig('error', 'message') || 'Unknown error'
164
+ raise msg
165
+ end
166
+ end
167
+
168
+ def hrana_value_to_ruby(cell)
169
+ return nil if cell.nil?
170
+
171
+ type = cell['type']
172
+ val = cell['value']
173
+
174
+ case type
175
+ when 'null' then nil
176
+ when 'integer' then val.to_i
177
+ when 'float' then val.to_f
178
+ when 'text' then val.to_s
179
+ when 'blob' then val.to_s
180
+ end
181
+ end
182
+
183
+ def hrana_url(url)
184
+ # libsql:// → https:// に変換
185
+ if url.start_with?('libsql://')
186
+ "https://#{url.sub('libsql://', '')}/v2/pipeline"
187
+ else
188
+ "#{url.chomp('/')}/v2/pipeline"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connection'
4
+
5
+ module TursoLibsql
6
+ # Database ラッパー
7
+ # リモート接続と Embedded Replica の両方をサポート
8
+ class Database
9
+ # リモート接続用 Database を作成
10
+ def self.new_remote(url, token)
11
+ new(mode: :remote, url: url, token: token)
12
+ end
13
+
14
+ # Embedded Replica 用 Database を作成
15
+ def self.new_remote_replica(path, url, token, sync_interval_secs = 0)
16
+ new(mode: :replica, path: path, url: url, token: token, sync_interval: sync_interval_secs)
17
+ end
18
+
19
+ # Offline write 用 Database を作成
20
+ def self.new_synced(path, url, token, sync_interval_secs = 0)
21
+ new(mode: :offline, path: path, url: url, token: token, sync_interval: sync_interval_secs)
22
+ end
23
+
24
+ def initialize(mode:, url: nil, token: nil, path: nil, sync_interval: 0)
25
+ @mode = mode
26
+ @url = url
27
+ @token = token || ''
28
+ @path = path
29
+ @sync_interval = sync_interval
30
+ end
31
+
32
+ # この Database から Connection を取得して返す
33
+ def connect
34
+ case @mode
35
+ when :remote
36
+ Connection.new(@url, @token)
37
+ when :replica, :offline
38
+ # ローカルファイルを開く(なければ作成される)
39
+ LocalConnection.new(@path, @url, @token, @mode)
40
+ end
41
+ end
42
+
43
+ # リモートから最新フレームを手動で同期する
44
+ def sync
45
+ case @mode
46
+ when :remote
47
+ # remote モードでは no-op
48
+ nil
49
+ when :replica, :offline
50
+ replica_sync(@path, @url, @token, @mode == :offline)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def replica_sync(path, url, token, offline)
57
+ remote_conn = Connection.new(url, token)
58
+
59
+ # ローカル DB を開く
60
+ require 'sqlite3'
61
+ local = SQLite3::Database.new(path)
62
+ local.results_as_hash = true
63
+
64
+ # remote からテーブル一覧を取得
65
+ tables = remote_conn.query(
66
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
67
+ ).map { |r| r['name'] }
68
+
69
+ tables.each do |table|
70
+ # remote からスキーマを取得
71
+ schema_rows = remote_conn.query(
72
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='#{table.gsub("'", "''")}'"
73
+ )
74
+ next if schema_rows.empty?
75
+
76
+ schema = schema_rows.first['sql']
77
+ # CREATE TABLE "foo" (...) → CREATE TABLE IF NOT EXISTS "foo" (...)
78
+ create_sql = schema.sub(/\ACREATE TABLE\b/i, 'CREATE TABLE IF NOT EXISTS')
79
+ local.execute(create_sql)
80
+
81
+ # pull: remote → local(offline モードも pull してからローカル write を優先)
82
+ remote_rows = remote_conn.query("SELECT * FROM \"#{table.gsub('"', '""')}\"")
83
+ remote_rows.each do |row|
84
+ cols = row.keys.map { |c| "\"#{c.gsub('"', '""')}\"" }.join(', ')
85
+ vals = row.values.map { |v| v.nil? ? 'NULL' : "'#{v.to_s.gsub("'", "''")}'" }.join(', ')
86
+ local.execute("INSERT OR REPLACE INTO \"#{table.gsub('"', '""')}\" (#{cols}) VALUES (#{vals})")
87
+ end
88
+
89
+ next unless offline
90
+
91
+ # push: local → remote(offline モードのみ)
92
+ # results_as_hash = true なので Hash の配列が返る
93
+ local_rows = local.execute("SELECT * FROM \"#{table.gsub('"', '""')}\"")
94
+ next if local_rows.empty?
95
+
96
+ local_rows.each do |row|
97
+ cols = row.keys.map { |c| "\"#{c.gsub('"', '""')}\"" }.join(', ')
98
+ vals = row.values.map { |v| v.nil? ? 'NULL' : "'#{v.to_s.gsub("'", "''")}'" }.join(', ')
99
+ remote_conn.execute("INSERT OR REPLACE INTO \"#{table.gsub('"', '""')}\" (#{cols}) VALUES (#{vals})")
100
+ end
101
+ end
102
+
103
+ local.close
104
+ end
105
+ end
106
+
107
+ # ローカル SQLite 接続(Embedded Replica 用)
108
+ # sqlite3 gem を使用
109
+ class LocalConnection
110
+ def initialize(path, remote_url, token, mode)
111
+ require 'sqlite3'
112
+ @db = SQLite3::Database.new(path)
113
+ @db.results_as_hash = true
114
+ @remote_url = remote_url
115
+ @token = token
116
+ @mode = mode
117
+ @last_insert_rowid = 0
118
+ @last_affected_rows = 0
119
+ end
120
+
121
+ def execute(sql)
122
+ @db.execute(sql)
123
+ @last_affected_rows = @db.changes
124
+ @last_insert_rowid = @db.last_insert_row_id
125
+ @last_affected_rows
126
+ end
127
+
128
+ def query(sql)
129
+ @db.execute(sql)
130
+ end
131
+
132
+ def execute_with_params(sql, params)
133
+ @db.execute(sql, params)
134
+ @last_affected_rows = @db.changes
135
+ @last_insert_rowid = @db.last_insert_row_id
136
+ @last_affected_rows
137
+ end
138
+
139
+ def begin_transaction
140
+ @db.execute('BEGIN')
141
+ end
142
+
143
+ def commit_transaction
144
+ @db.execute('COMMIT')
145
+ end
146
+
147
+ def rollback_transaction
148
+ @db.execute('ROLLBACK')
149
+ end
150
+
151
+ attr_reader :last_insert_rowid
152
+ end
153
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'turso_libsql/connection'
4
+ require_relative 'turso_libsql/database'
5
+
6
+ module TursoLibsql
7
+ # fork 後の子プロセスで呼ぶ(Ruby 実装では no-op)
8
+ # Net::HTTP は fork 後も安全なため何もしなくてよい
9
+ def self.reinitialize_runtime!
10
+ # no-op: Ruby の Net::HTTP は fork 後も安全
11
+ end
12
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-libsql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - aileron
@@ -24,19 +24,19 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: rb_sys
27
+ name: sqlite3
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0.9'
32
+ version: '1.4'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0.9'
39
+ version: '1.4'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: rake
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -81,25 +81,23 @@ dependencies:
81
81
  version: '3.0'
82
82
  description: |
83
83
  An ActiveRecord adapter for Turso, the edge SQLite database powered by libSQL.
84
- Uses a native Rust extension (via magnus) to connect directly to Turso via the
85
- libSQL remote protocol, without requiring any external HTTP client.
84
+ Connects to Turso Cloud via the Hrana v2 HTTP protocol using Ruby's built-in
85
+ Net::HTTP, making it fork-safe and dependency-free. Supports Embedded Replica
86
+ mode via the sqlite3 gem for local read performance.
86
87
  email: []
87
88
  executables: []
88
- extensions:
89
- - ext/turso_libsql/extconf.rb
89
+ extensions: []
90
90
  extra_rdoc_files: []
91
91
  files:
92
- - Cargo.lock
93
- - Cargo.toml
94
92
  - README.md
95
93
  - activerecord-libsql.gemspec
96
- - ext/turso_libsql/Cargo.toml
97
- - ext/turso_libsql/extconf.rb
98
- - ext/turso_libsql/src/lib.rs
99
94
  - lib/active_record/connection_adapters/libsql_adapter.rb
100
95
  - lib/activerecord-libsql.rb
101
96
  - lib/activerecord/libsql/railtie.rb
102
97
  - lib/activerecord/libsql/version.rb
98
+ - lib/turso_libsql.rb
99
+ - lib/turso_libsql/connection.rb
100
+ - lib/turso_libsql/database.rb
103
101
  homepage: https://github.com/aileron-inc/activerecord-libsql
104
102
  licenses:
105
103
  - MIT