cloudflare-d1 0.1.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.
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "sequel/adapters/utils/unmodified_identifiers"
5
+ require "cloudflare/d1/client"
6
+
7
+ module Sequel
8
+ module CloudflareD1
9
+ class Database < Sequel::Database
10
+ include Sequel::UnmodifiedIdentifiers::DatabaseMethods
11
+
12
+ set_adapter_scheme :cloudflare_d1
13
+
14
+ def connect(server)
15
+ opts = server_opts(server)
16
+
17
+ @account_id = opts[:account_id] || ENV["CLOUDFLARE_ACCOUNT_ID"]
18
+ @api_token = opts[:api_token] || ENV["CLOUDFLARE_API_TOKEN"]
19
+ @database_id = opts[:database] || opts[:database_id]
20
+
21
+ raise Error, "account_id is required" unless @account_id
22
+ raise Error, "api_token is required" unless @api_token
23
+ raise Error, "database_id is required" unless @database_id
24
+
25
+ @client = ::Cloudflare::D1::Client.new(
26
+ account_id: @account_id,
27
+ api_token: @api_token
28
+ )
29
+ end
30
+
31
+ def disconnect_connection(conn)
32
+ # No persistent connection to close
33
+ end
34
+
35
+ def execute(sql, opts = OPTS)
36
+ synchronize(opts[:server]) do |conn|
37
+ result = @client.query(
38
+ database_id: @database_id,
39
+ sql: sql
40
+ )
41
+
42
+ result.dig("result", 0, "results")
43
+ end
44
+ end
45
+
46
+ def execute_dui(sql, opts = OPTS)
47
+ synchronize(opts[:server]) do |conn|
48
+ result = @client.query(
49
+ database_id: @database_id,
50
+ sql: sql
51
+ )
52
+
53
+ meta = result.dig("result", 0, "meta")
54
+ meta["changes"] || 0
55
+ end
56
+ end
57
+
58
+ def execute_insert(sql, opts = OPTS)
59
+ synchronize(opts[:server]) do |conn|
60
+ result = @client.query(
61
+ database_id: @database_id,
62
+ sql: sql
63
+ )
64
+
65
+ meta = result.dig("result", 0, "meta")
66
+ meta["last_row_id"]
67
+ end
68
+ end
69
+
70
+ def fetch_rows(sql)
71
+ rows = execute(sql)
72
+ rows.each { |row| yield row } if rows
73
+ end
74
+
75
+ def execute_ddl(sql, opts = OPTS)
76
+ execute(sql, opts)
77
+ nil
78
+ end
79
+
80
+ # Schema methods
81
+
82
+ def tables(opts = OPTS)
83
+ sql = <<-SQL
84
+ SELECT name FROM sqlite_master
85
+ WHERE type = 'table'
86
+ AND name NOT LIKE 'sqlite_%'
87
+ ORDER BY name
88
+ SQL
89
+
90
+ rows = execute(sql)
91
+ rows.map { |row| row["name"].to_sym }
92
+ end
93
+
94
+ def schema(table, opts = OPTS)
95
+ sql = "PRAGMA table_info(#{literal(table.to_s)})"
96
+ rows = execute(sql)
97
+
98
+ rows.map do |row|
99
+ column = row["name"].to_sym
100
+ info = {
101
+ type: type_literal_to_sequel_type(row["type"]),
102
+ allow_null: row["notnull"] == 0,
103
+ default: row["dflt_value"],
104
+ primary_key: row["pk"] == 1,
105
+ db_type: row["type"]
106
+ }
107
+ [column, info]
108
+ end
109
+ end
110
+
111
+ def indexes(table, opts = OPTS)
112
+ sql = "PRAGMA index_list(#{literal(table.to_s)})"
113
+ index_list = execute(sql)
114
+
115
+ index_list.map do |idx|
116
+ index_name = idx["name"]
117
+ unique = idx["unique"] == 1
118
+
119
+ columns_sql = "PRAGMA index_info(#{literal(index_name)})"
120
+ columns = execute(columns_sql)
121
+ .sort_by { |c| c["seqno"] }
122
+ .map { |c| c["name"].to_sym }
123
+
124
+ {
125
+ name: index_name.to_sym,
126
+ unique: unique,
127
+ columns: columns
128
+ }
129
+ end
130
+ end
131
+
132
+ def supports_create_table_if_not_exists?
133
+ true
134
+ end
135
+
136
+ def supports_drop_table_if_exists?
137
+ true
138
+ end
139
+
140
+ def supports_transaction_isolation_levels?
141
+ false
142
+ end
143
+
144
+ def supports_savepoints?
145
+ false
146
+ end
147
+
148
+ def supports_prepared_transactions?
149
+ false
150
+ end
151
+
152
+ def supports_deferrable_constraints?
153
+ true
154
+ end
155
+
156
+ def supports_transactional_ddl?
157
+ false
158
+ end
159
+
160
+ def transaction(opts = OPTS)
161
+ # D1 API doesn't support transactions across HTTP requests
162
+ # So we just yield without transaction support
163
+ yield
164
+ end
165
+
166
+ def database_type
167
+ :cloudflare_d1
168
+ end
169
+
170
+ def dataset_class_default
171
+ Dataset
172
+ end
173
+
174
+ private
175
+
176
+ def type_literal_to_sequel_type(type)
177
+ case type.to_s.upcase
178
+ when /INT/i
179
+ :integer
180
+ when /REAL|FLOAT|DOUBLE/i
181
+ :float
182
+ when /TEXT|CHAR|CLOB/i
183
+ :string
184
+ when /BLOB/i
185
+ :blob
186
+ when /NUMERIC|DECIMAL/i
187
+ :decimal
188
+ else
189
+ :string
190
+ end
191
+ end
192
+ end
193
+
194
+ class Dataset < Sequel::Dataset
195
+ include Sequel::UnmodifiedIdentifiers::DatasetMethods
196
+
197
+ def fetch_rows(sql)
198
+ rows = execute(sql)
199
+ if rows
200
+ rows.each do |row|
201
+ # Convert string keys to symbols
202
+ symbolized_row = row.transform_keys(&:to_sym)
203
+ yield symbolized_row
204
+ end
205
+ end
206
+ end
207
+
208
+ def insert_select(*values)
209
+ # D1 doesn't support RETURNING clause
210
+ execute_insert(insert_sql(*values))
211
+ end
212
+
213
+ def supports_insert_select?
214
+ false
215
+ end
216
+
217
+ def supports_returning?(type)
218
+ false
219
+ end
220
+
221
+ def supports_window_functions?
222
+ true
223
+ end
224
+
225
+ def supports_cte?
226
+ true
227
+ end
228
+
229
+ def supports_where_true?
230
+ true
231
+ end
232
+
233
+ def supports_derived_column_lists?
234
+ false
235
+ end
236
+
237
+ def complex_expression_sql_append(sql, op, args)
238
+ case op
239
+ when :'||'
240
+ # SQLite uses || for concatenation
241
+ super
242
+ else
243
+ super
244
+ end
245
+ end
246
+
247
+ def literal_false
248
+ "0"
249
+ end
250
+
251
+ def literal_true
252
+ "1"
253
+ end
254
+
255
+ def quote_identifiers?
256
+ true
257
+ end
258
+
259
+ def quoted_identifier_append(sql, name)
260
+ sql << '"' << name.to_s.gsub('"', '""') << '"'
261
+ end
262
+ end
263
+ end
264
+ end
265
+
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloudflare/d1/client"
4
+
5
+ namespace :db do
6
+ desc "Create the Cloudflare D1 database"
7
+ task create: :load_config do
8
+ config = ActiveRecord::Base.connection_config
9
+ client = Cloudflare::D1::Client.new(
10
+ account_id: config[:account_id] || ENV["CLOUDFLARE_ACCOUNT_ID"],
11
+ api_token: config[:api_token] || ENV["CLOUDFLARE_API_TOKEN"]
12
+ )
13
+
14
+ database_name = config[:database] || config[:database_id]
15
+
16
+ if database_name =~ /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
17
+ puts "Database ID already exists: #{database_name}"
18
+ puts "Skipping creation."
19
+ else
20
+ begin
21
+ result = client.create_database(name: database_name)
22
+ puts "Created database: #{result['name']} (#{result['uuid']})"
23
+ puts ""
24
+ puts "Add this to your database.yml:"
25
+ puts " database_id: #{result['uuid']}"
26
+ rescue Cloudflare::D1::Client::APIError => e
27
+ if e.message.include?("already exists")
28
+ puts "Database already exists: #{database_name}"
29
+ else
30
+ raise
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ desc "Drop the Cloudflare D1 database"
37
+ task drop: :load_config do
38
+ config = ActiveRecord::Base.connection_config
39
+ client = Cloudflare::D1::Client.new(
40
+ account_id: config[:account_id] || ENV["CLOUDFLARE_ACCOUNT_ID"],
41
+ api_token: config[:api_token] || ENV["CLOUDFLARE_API_TOKEN"]
42
+ )
43
+
44
+ database_id = config[:database_id] || config[:database]
45
+
46
+ unless database_id =~ /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
47
+ abort "Error: database_id must be a UUID. Cannot drop database by name."
48
+ end
49
+
50
+ print "Are you sure you want to drop database #{database_id}? [y/N] "
51
+ confirmation = STDIN.gets.chomp
52
+
53
+ if confirmation.downcase == "y"
54
+ client.delete_database(database_id: database_id)
55
+ puts "Dropped database: #{database_id}"
56
+ else
57
+ puts "Cancelled."
58
+ end
59
+ end
60
+
61
+ desc "List all Cloudflare D1 databases"
62
+ task list: :load_config do
63
+ config = ActiveRecord::Base.connection_config
64
+ client = Cloudflare::D1::Client.new(
65
+ account_id: config[:account_id] || ENV["CLOUDFLARE_ACCOUNT_ID"],
66
+ api_token: config[:api_token] || ENV["CLOUDFLARE_API_TOKEN"]
67
+ )
68
+
69
+ databases = client.list_databases
70
+
71
+ if databases.empty?
72
+ puts "No databases found."
73
+ else
74
+ puts "Cloudflare D1 Databases:"
75
+ puts "-" * 80
76
+ databases.each do |db|
77
+ puts "Name: #{db['name']}"
78
+ puts "UUID: #{db['uuid']}"
79
+ puts "Created: #{db['created_at']}"
80
+ puts "Tables: #{db['num_tables']}"
81
+ puts "-" * 80
82
+ end
83
+ end
84
+ end
85
+
86
+ desc "Migrate the Cloudflare D1 database (run pending migrations)"
87
+ task migrate: :load_config do
88
+ ActiveRecord::Base.establish_connection
89
+ ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true
90
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths).migrate
91
+ end
92
+
93
+ desc "Rollback the database one migration"
94
+ task rollback: :load_config do
95
+ step = ENV["STEP"] ? ENV["STEP"].to_i : 1
96
+ ActiveRecord::Base.establish_connection
97
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths).rollback(step)
98
+ end
99
+
100
+ desc "Display migration status"
101
+ task :migrate_status => :load_config do
102
+ ActiveRecord::Base.establish_connection
103
+
104
+ unless ActiveRecord::Base.connection.table_exists?("schema_migrations")
105
+ puts "Schema migrations table doesn't exist yet."
106
+ return
107
+ end
108
+
109
+ db_list = ActiveRecord::SchemaMigration.all_versions
110
+
111
+ puts "\nDatabase: #{ActiveRecord::Base.connection_config[:database_id]}\n\n"
112
+ puts "#{'Status'.center(10)} #{'Migration ID'.ljust(14)} Migration Name"
113
+ puts "-" * 80
114
+
115
+ ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths).migrations.each do |migration|
116
+ status = db_list.include?(migration.version.to_s) ? "up" : "down"
117
+ puts "#{status.center(10)} #{migration.version.to_s.ljust(14)} #{migration.name}"
118
+ end
119
+
120
+ puts
121
+ end
122
+
123
+ desc "Reset the database (drop, create, migrate)"
124
+ task reset: [:drop, :create, :migrate]
125
+
126
+ desc "Setup the database (create and migrate)"
127
+ task setup: [:create, :migrate]
128
+
129
+ task :load_config do
130
+ require "active_record"
131
+ require "active_record/connection_adapters/cloudflare_d1_adapter"
132
+
133
+ # Load Rails database configuration if available
134
+ if defined?(Rails)
135
+ ActiveRecord::Base.configurations = Rails.application.config.database_configuration
136
+ ActiveRecord::Base.establish_connection(Rails.env.to_sym)
137
+ else
138
+ # Load from config/database.yml if it exists
139
+ db_config_path = File.expand_path("config/database.yml", Dir.pwd)
140
+ if File.exist?(db_config_path)
141
+ require "yaml"
142
+ require "erb"
143
+ configs = YAML.load(ERB.new(File.read(db_config_path)).result) || {}
144
+ env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
145
+ config = configs[env]
146
+
147
+ if config
148
+ ActiveRecord::Base.configurations = { env => config }
149
+ ActiveRecord::Base.establish_connection(config)
150
+ else
151
+ abort "No database configuration found for environment: #{env}"
152
+ end
153
+ else
154
+ abort "No database configuration found. Create config/database.yml or set ENV variables."
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel"
4
+ require "cloudflare/d1/client"
5
+
6
+ namespace :sequel do
7
+ namespace :db do
8
+ desc "Create the Cloudflare D1 database"
9
+ task :create do
10
+ require_sequel_connection_config
11
+
12
+ database_name = ENV["DATABASE_NAME"] || abort("DATABASE_NAME environment variable required")
13
+
14
+ if database_name =~ /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
15
+ puts "Database ID already exists: #{database_name}"
16
+ puts "Skipping creation."
17
+ else
18
+ client = cloudflare_client
19
+ begin
20
+ result = client.create_database(name: database_name)
21
+ puts "Created database: #{result['name']} (#{result['uuid']})"
22
+ puts ""
23
+ puts "Set this environment variable:"
24
+ puts " DATABASE_URL=cloudflare_d1://#{result['uuid']}"
25
+ rescue Cloudflare::D1::Client::APIError => e
26
+ if e.message.include?("already exists")
27
+ puts "Database already exists: #{database_name}"
28
+ else
29
+ raise
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ desc "Drop the Cloudflare D1 database"
36
+ task :drop do
37
+ require_sequel_connection_config
38
+
39
+ database_id = ENV["DATABASE_ID"] || abort("DATABASE_ID environment variable required")
40
+
41
+ unless database_id =~ /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
42
+ abort "Error: DATABASE_ID must be a UUID"
43
+ end
44
+
45
+ print "Are you sure you want to drop database #{database_id}? [y/N] "
46
+ confirmation = STDIN.gets.chomp
47
+
48
+ if confirmation.downcase == "y"
49
+ client = cloudflare_client
50
+ client.delete_database(database_id: database_id)
51
+ puts "Dropped database: #{database_id}"
52
+ else
53
+ puts "Cancelled."
54
+ end
55
+ end
56
+
57
+ desc "List all Cloudflare D1 databases"
58
+ task :list do
59
+ require_sequel_connection_config
60
+
61
+ client = cloudflare_client
62
+ databases = client.list_databases
63
+
64
+ if databases.empty?
65
+ puts "No databases found."
66
+ else
67
+ puts "Cloudflare D1 Databases:"
68
+ puts "-" * 80
69
+ databases.each do |db|
70
+ puts "Name: #{db['name']}"
71
+ puts "UUID: #{db['uuid']}"
72
+ puts "Created: #{db['created_at']}"
73
+ puts "Tables: #{db['num_tables']}"
74
+ puts "-" * 80
75
+ end
76
+ end
77
+ end
78
+
79
+ desc "Run migrations"
80
+ task :migrate, [:version] do |t, args|
81
+ require_sequel_connection_config
82
+ Sequel.extension :migration
83
+
84
+ db_url = ENV["DATABASE_URL"] || abort("DATABASE_URL environment variable required")
85
+ migrations_path = ENV["MIGRATIONS_PATH"] || "db/migrations"
86
+
87
+ Sequel.connect(db_url) do |db|
88
+ version = args[:version].to_i if args[:version]
89
+
90
+ if version
91
+ puts "Migrating to version #{version}"
92
+ Sequel::Migrator.run(db, migrations_path, target: version)
93
+ else
94
+ puts "Migrating to latest version"
95
+ Sequel::Migrator.run(db, migrations_path)
96
+ end
97
+
98
+ puts "Migration complete"
99
+ end
100
+ end
101
+
102
+ desc "Rollback the database one migration"
103
+ task :rollback, [:step] do |t, args|
104
+ require_sequel_connection_config
105
+ Sequel.extension :migration
106
+
107
+ db_url = ENV["DATABASE_URL"] || abort("DATABASE_URL environment variable required")
108
+ migrations_path = ENV["MIGRATIONS_PATH"] || "db/migrations"
109
+
110
+ Sequel.connect(db_url) do |db|
111
+ # Get current version
112
+ if db.table_exists?(:schema_info)
113
+ current_version = db[:schema_info].get(:version)
114
+
115
+ if current_version && current_version > 0
116
+ step = args[:step] ? args[:step].to_i : 1
117
+
118
+ # Get list of migrations
119
+ migrations = Dir[File.join(migrations_path, "*.rb")]
120
+ .map { |f| File.basename(f).split("_").first.to_i }
121
+ .sort
122
+
123
+ # Find target version
124
+ current_idx = migrations.index(current_version)
125
+ if current_idx && current_idx > 0
126
+ target_version = migrations[current_idx - step]
127
+
128
+ puts "Rolling back from version #{current_version} to #{target_version}"
129
+ Sequel::Migrator.run(db, migrations_path, target: target_version)
130
+ puts "Rollback complete"
131
+ else
132
+ puts "Already at first migration"
133
+ end
134
+ else
135
+ puts "No migrations to rollback"
136
+ end
137
+ else
138
+ puts "Schema info table doesn't exist yet"
139
+ end
140
+ end
141
+ end
142
+
143
+ desc "Display migration status"
144
+ task :status do
145
+ require_sequel_connection_config
146
+ Sequel.extension :migration
147
+
148
+ db_url = ENV["DATABASE_URL"] || abort("DATABASE_URL environment variable required")
149
+ migrations_path = ENV["MIGRATIONS_PATH"] || "db/migrations"
150
+
151
+ Sequel.connect(db_url) do |db|
152
+ unless db.table_exists?(:schema_info)
153
+ puts "Schema info table doesn't exist yet."
154
+ return
155
+ end
156
+
157
+ current_version = db[:schema_info].get(:version) || 0
158
+
159
+ puts "\nDatabase: #{ENV['DATABASE_ID'] || ENV['DATABASE_URL']}\n"
160
+ puts "Current version: #{current_version}\n\n"
161
+ puts "#{'Status'.center(10)} #{'Version'.ljust(14)} Migration File"
162
+ puts "-" * 80
163
+
164
+ migrations = Dir[File.join(migrations_path, "*.rb")].sort
165
+ migrations.each do |migration_file|
166
+ filename = File.basename(migration_file)
167
+ version = filename.split("_").first.to_i
168
+ name = filename.gsub(/^\d+_/, "").gsub(/\.rb$/, "")
169
+
170
+ status = version <= current_version ? "up" : "down"
171
+ puts "#{status.center(10)} #{version.to_s.ljust(14)} #{name}"
172
+ end
173
+
174
+ puts
175
+ end
176
+ end
177
+
178
+ desc "Reset the database (drop, create, migrate)"
179
+ task reset: [:drop, :create, :migrate]
180
+
181
+ desc "Setup the database (create and migrate)"
182
+ task setup: [:create, :migrate]
183
+ end
184
+
185
+ def require_sequel_connection_config
186
+ require "sequel"
187
+ require "sequel/adapters/cloudflare_d1"
188
+ end
189
+
190
+ def cloudflare_client
191
+ account_id = ENV["CLOUDFLARE_ACCOUNT_ID"] || abort("CLOUDFLARE_ACCOUNT_ID environment variable required")
192
+ api_token = ENV["CLOUDFLARE_API_TOKEN"] || abort("CLOUDFLARE_API_TOKEN environment variable required")
193
+
194
+ Cloudflare::D1::Client.new(
195
+ account_id: account_id,
196
+ api_token: api_token
197
+ )
198
+ end
199
+ end
@@ -0,0 +1,6 @@
1
+ module Cloudflare
2
+ module D1
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end