hotswap 0.1.5 → 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: cfc6ba1c55a44bc480160c4688fd4dbd32c0d389c8c532738e04d3031d14b538
4
- data.tar.gz: 6de63bb11a1ab974a55cc8900e8dc063d6f25f3e4f478f098139b1af82fe327a
3
+ metadata.gz: e2313b39ed36d4fd0d236192de9c34f9ac7546984f8a076d474f035abddef947
4
+ data.tar.gz: a7458d108f6d2051a08c89aa3a8357c3cb72ccb892c7669b69d3deeaf1f5abe3
5
5
  SHA512:
6
- metadata.gz: 96fc1c62b5518aef27b08e6cf0fd6a9fafabda30b5be3b199e1a2ecd86d15c177d83c35641b357161e8bec431e53d7db80f859db54971db20088c0e3449da349
7
- data.tar.gz: 6bd93a4ab5c061c6d97b77eab76af68ae93675b584648742ce74c950177f76623c6e0691e07310692f22e40d09592bff78d40eead14a7ba5ce103d190f8e94a6
6
+ metadata.gz: 53ea80ca58d9f342fc04bc51385674ee5b5f8d3dd64a63a18869b5d4fdc81f8ac74c80a6901991d8709d65aeb565d19a5ca3cbeaa19e1a0408ab4ca9ce0dd154
7
+ data.tar.gz: dfbb24c4a439443db7e43d19f65b9964c86505eda6479fa6ec4ca75cba93b25cc619a6d845aa6ca16a822bc187c2f53f8cabdbf66d4ffae98dc9e93682324fbb
data/lib/hotswap/cli.rb CHANGED
@@ -1,7 +1,4 @@
1
1
  require "thor"
2
- require "tempfile"
3
- require "fileutils"
4
- require "sqlite3"
5
2
 
6
3
  module Hotswap
7
4
  class CLI < Thor
@@ -20,8 +17,6 @@ module Hotswap
20
17
  def stderr = @_stderr
21
18
  end
22
19
 
23
- # Thread-safe IO: each connection gets its own IO via thread-local storage
24
- # instead of swapping global $stdin/$stdout/$stderr.
25
20
  def self.run(args, stdin: $stdin, stdout: $stdout, stderr: $stderr)
26
21
  Thread.current[:hotswap_stdin] = stdin
27
22
  Thread.current[:hotswap_stdout] = stdout
@@ -37,52 +32,42 @@ module Hotswap
37
32
  Thread.current[:hotswap_stderr] = nil
38
33
  end
39
34
 
40
- desc "cp SRC DST", "Copy a database to/from the running server. Use 'database' to refer to the running database."
35
+ desc "cp SRC DST", "Copy a database to/from the running server"
41
36
  long_desc <<~DESC
42
37
  Copy a SQLite database to or from the running server.
43
38
 
44
- Use 'database' as a placeholder for the running server's database.
45
- Use '-' for stdin/stdout.
39
+ If SRC or DST matches a managed database path, hotswap treats it as
40
+ the live database. Use '-' for stdin/stdout.
46
41
 
47
42
  Examples:
48
43
 
49
- hotswap cp ./new.sqlite3 database # replace the running database
50
- hotswap cp database ./backup.sqlite3 # snapshot the running database
51
- hotswap cp - database # read from stdin
52
- hotswap cp database - # write to stdout
44
+ hotswap cp ./new.sqlite3 db/production.sqlite3 # push
45
+ hotswap cp db/production.sqlite3 ./backup.sqlite3 # pull
46
+ hotswap cp - db/production.sqlite3 # push from stdin
47
+ hotswap cp db/production.sqlite3 - # pull to stdout
53
48
  DESC
54
49
  def cp(src, dst)
55
- db_path = Hotswap.database_path
56
- unless db_path
57
- io_err.write("ERROR: database_path not configured\n")
58
- return
59
- end
50
+ src_db = resolve_database(src)
51
+ dst_db = resolve_database(dst)
60
52
 
61
- if src == "database" && dst == "database"
62
- io_err.write("ERROR: source and destination can't both be 'database'\n")
53
+ if src_db && dst_db
54
+ io_err.write("ERROR: source and destination can't both be managed databases\n")
63
55
  return
64
56
  end
65
57
 
66
- if dst == "database"
67
- push_database(src, db_path)
68
- elsif src == "database"
69
- pull_database(dst, db_path)
58
+ if dst_db
59
+ source = (src == "-") ? io_in : src
60
+ dst_db.push(source, stdout: io_out, stderr: io_err)
61
+ elsif src_db
62
+ destination = (dst == "-") ? io_out : dst
63
+ src_db.pull(destination, stderr: io_err)
70
64
  else
71
- io_err.write("ERROR: one of src/dst must be 'database'\n")
65
+ paths = Hotswap.databases.map(&:path).join(", ")
66
+ io_err.write("ERROR: neither path matches a managed database (#{paths})\n")
72
67
  end
73
68
  end
74
69
 
75
- desc "push", "Replace the running database from stdin"
76
- def push
77
- cp("-", "database")
78
- end
79
-
80
- desc "pull", "Snapshot the running database to stdout"
81
- def pull
82
- cp("database", "-")
83
- end
84
-
85
- desc "version", "Print the hotswap version"
70
+ desc "version", "Print the hotswap version"
86
71
  def version
87
72
  io_out.write("hotswap #{Hotswap::VERSION}\n")
88
73
  end
@@ -93,73 +78,9 @@ module Hotswap
93
78
  def io_out = Thread.current[:hotswap_stdout] || $stdout
94
79
  def io_err = Thread.current[:hotswap_stderr] || $stderr
95
80
 
96
- def push_database(src, db_path)
97
- input = (src == "-") ? io_in : File.open(src, "rb")
98
-
99
- dir = File.dirname(db_path)
100
- temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
101
- begin
102
- IO.copy_stream(input, temp)
103
- temp.close
104
-
105
- db = SQLite3::Database.new(temp.path)
106
- result = db.execute("PRAGMA integrity_check")
107
- db.close
108
- unless result == [["ok"]]
109
- io_err.write("ERROR: integrity check failed\n")
110
- return
111
- end
112
-
113
- io_err.write("Swapping database...\n")
114
-
115
- Middleware::SWAP_LOCK.synchronize do
116
- if defined?(ActiveRecord::Base)
117
- ActiveRecord::Base.connection_handler.clear_all_connections!
118
- end
119
- File.rename(temp.path, db_path)
120
- end
121
-
122
- if defined?(ActiveRecord::Base)
123
- ActiveRecord::Base.establish_connection
124
- end
125
-
126
- io_out.write("OK\n")
127
- rescue => e
128
- io_err.write("ERROR: #{e.message}\n")
129
- ensure
130
- input.close if input.is_a?(File)
131
- temp.unlink if temp && File.exist?(temp.path)
132
- end
133
- end
134
-
135
- def pull_database(dst, db_path)
136
- unless File.exist?(db_path)
137
- io_err.write("ERROR: database file not found\n")
138
- return
139
- end
140
-
141
- dir = File.dirname(db_path)
142
- temp = Tempfile.new(["hotswap-pull", ".sqlite3"], dir)
143
- begin
144
- src_db = SQLite3::Database.new(db_path)
145
- dst_db = SQLite3::Database.new(temp.path)
146
- b = SQLite3::Backup.new(dst_db, "main", src_db, "main")
147
- b.step(-1)
148
- b.finish
149
- dst_db.close
150
- src_db.close
151
-
152
- if dst == "-"
153
- File.open(temp.path, "rb") { |f| IO.copy_stream(f, io_out) }
154
- else
155
- FileUtils.cp(temp.path, dst)
156
- io_err.write("OK\n")
157
- end
158
- rescue => e
159
- io_err.write("ERROR: #{e.message}\n")
160
- ensure
161
- temp.unlink if temp && File.exist?(temp.path)
162
- end
81
+ def resolve_database(path)
82
+ return nil if path == "-"
83
+ Hotswap.find_database(path) || (path == "database" && Hotswap.databases.first)
163
84
  end
164
85
  end
165
86
  end
@@ -0,0 +1,111 @@
1
+ require "tempfile"
2
+ require "fileutils"
3
+ require "sqlite3"
4
+
5
+ module Hotswap
6
+ class Database
7
+ attr_reader :path
8
+
9
+ def initialize(path)
10
+ @path = File.expand_path(path)
11
+ end
12
+
13
+ # Push a new database from an IO stream or file path
14
+ def push(source, stdout: $stdout, stderr: $stderr)
15
+ source_label = source.is_a?(String) ? source : "stdin"
16
+ logger.info "push started: #{source_label} → #{@path}"
17
+
18
+ input = source.is_a?(String) ? File.open(source, "rb") : source
19
+
20
+ dir = File.dirname(@path)
21
+ temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
22
+ begin
23
+ IO.copy_stream(input, temp)
24
+ temp.close
25
+ logger.info "received #{File.size(temp.path)} bytes, running integrity check"
26
+
27
+ db = SQLite3::Database.new(temp.path)
28
+ result = db.execute("PRAGMA integrity_check")
29
+ db.close
30
+ unless result == [["ok"]]
31
+ logger.error "integrity check failed for #{source_label}"
32
+ stderr.write("ERROR: integrity check failed\n")
33
+ return false
34
+ end
35
+
36
+ logger.info "integrity check passed, acquiring swap lock"
37
+ stderr.write("Swapping database...\n")
38
+
39
+ Middleware::SWAP_LOCK.synchronize do
40
+ if defined?(ActiveRecord::Base)
41
+ ActiveRecord::Base.connection_handler.clear_all_connections!
42
+ logger.info "disconnected ActiveRecord"
43
+ end
44
+ File.rename(temp.path, @path)
45
+ logger.info "renamed #{temp.path} → #{@path}"
46
+ end
47
+
48
+ if defined?(ActiveRecord::Base)
49
+ ActiveRecord::Base.establish_connection
50
+ logger.info "reconnected ActiveRecord"
51
+ end
52
+
53
+ logger.info "push complete: #{@path}"
54
+ stdout.write("OK\n")
55
+ true
56
+ rescue => e
57
+ logger.error "push failed: #{e.message}"
58
+ stderr.write("ERROR: #{e.message}\n")
59
+ false
60
+ ensure
61
+ input.close if source.is_a?(String) && input.is_a?(File)
62
+ temp.unlink if temp && File.exist?(temp.path)
63
+ end
64
+ end
65
+
66
+ # Pull the database to an IO stream or file path
67
+ def pull(destination, stderr: $stderr)
68
+ dest_label = destination.is_a?(String) ? destination : "stdout"
69
+ logger.info "pull started: #{@path} → #{dest_label}"
70
+
71
+ unless File.exist?(@path)
72
+ logger.error "database file not found at #{@path}"
73
+ stderr.write("ERROR: database file not found at #{@path}\n")
74
+ return false
75
+ end
76
+
77
+ dir = File.dirname(@path)
78
+ temp = Tempfile.new(["hotswap-pull", ".sqlite3"], dir)
79
+ begin
80
+ src_db = SQLite3::Database.new(@path)
81
+ dst_db = SQLite3::Database.new(temp.path)
82
+ b = SQLite3::Backup.new(dst_db, "main", src_db, "main")
83
+ b.step(-1)
84
+ b.finish
85
+ dst_db.close
86
+ src_db.close
87
+ logger.info "backup complete: #{File.size(temp.path)} bytes"
88
+
89
+ if destination.is_a?(String)
90
+ FileUtils.cp(temp.path, destination)
91
+ logger.info "pull complete: #{@path} → #{destination}"
92
+ stderr.write("OK\n")
93
+ else
94
+ File.open(temp.path, "rb") { |f| IO.copy_stream(f, destination) }
95
+ logger.info "pull complete: #{@path} → stdout"
96
+ end
97
+ true
98
+ rescue => e
99
+ logger.error "pull failed: #{e.message}"
100
+ stderr.write("ERROR: #{e.message}\n")
101
+ false
102
+ ensure
103
+ temp.unlink if temp && File.exist?(temp.path)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def logger = Hotswap.logger
110
+ end
111
+ end
@@ -2,18 +2,26 @@ module Hotswap
2
2
  class Railtie < Rails::Railtie
3
3
  config.hotswap = ActiveSupport::OrderedOptions.new
4
4
 
5
+ initializer "hotswap.logger" do
6
+ Hotswap.logger = Rails.logger
7
+ end
8
+
5
9
  initializer "hotswap.configure" do |app|
6
- # Default database path from Rails config
7
- if app.config.hotswap.database_path
8
- Hotswap.database_path = app.config.hotswap.database_path
10
+ # Discover all SQLite databases from Rails config
11
+ if app.config.hotswap.database_paths
12
+ Array(app.config.hotswap.database_paths).each { |p| Hotswap.register(p) }
13
+ elsif app.config.hotswap.database_path
14
+ Hotswap.register(app.config.hotswap.database_path)
9
15
  else
10
- db_config = app.config.database_configuration[Rails.env]
11
- if db_config && db_config["adapter"]&.include?("sqlite")
12
- Hotswap.database_path = db_config["database"]
16
+ db_configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
17
+ db_configs.each do |db_config|
18
+ if db_config.adapter.include?("sqlite")
19
+ Hotswap.register(db_config.database)
20
+ end
13
21
  end
14
22
  end
15
23
 
16
- # Default socket paths
24
+ # Socket paths
17
25
  if app.config.hotswap.socket_path
18
26
  Hotswap.socket_path = app.config.hotswap.socket_path
19
27
  else
@@ -27,10 +27,15 @@ module Hotswap
27
27
 
28
28
  @thread = Thread.new { accept_loop }
29
29
  @thread.report_on_exception = false
30
+
31
+ logger.info "listening on #{@socket_path}"
32
+ logger.info "stderr socket on #{@stderr_socket_path}"
33
+ logger.info "managing #{Hotswap.databases.size} database(s): #{Hotswap.databases.map(&:path).join(', ')}"
30
34
  self
31
35
  end
32
36
 
33
37
  def stop
38
+ logger.info "shutting down"
34
39
  @server&.close
35
40
  @stderr_server&.close
36
41
  @thread&.kill
@@ -45,6 +50,8 @@ module Hotswap
45
50
 
46
51
  private
47
52
 
53
+ def logger = Hotswap.logger
54
+
48
55
  def accept_loop
49
56
  ios = [@server, @stderr_server]
50
57
  loop do
@@ -75,6 +82,7 @@ module Hotswap
75
82
 
76
83
  def handle_connection(socket)
77
84
  unless IO.select([socket], nil, nil, CONNECTION_TIMEOUT)
85
+ logger.warn "connection timed out"
78
86
  socket.write("ERROR: connection timeout\n") rescue nil
79
87
  return
80
88
  end
@@ -83,6 +91,7 @@ module Hotswap
83
91
  return unless line
84
92
 
85
93
  parts = Shellwords.split(line.strip)
94
+ logger.info "command: #{parts.join(' ')}" unless parts.empty?
86
95
 
87
96
  # Grab the stderr socket if one is waiting
88
97
  stderr_io = take_stderr_client
@@ -94,6 +103,7 @@ module Hotswap
94
103
  stderr: stderr_io || $stderr
95
104
  )
96
105
  rescue => e
106
+ logger.error "connection error: #{e.message}"
97
107
  socket.write("ERROR: #{e.message}\n") rescue nil
98
108
  ensure
99
109
  stderr_io&.close rescue nil
@@ -1,3 +1,3 @@
1
1
  module Hotswap
2
- VERSION = "0.1.5"
2
+ VERSION = "0.2.1"
3
3
  end
data/lib/hotswap.rb CHANGED
@@ -1,5 +1,7 @@
1
+ require "logger"
1
2
  require_relative "hotswap/version"
2
3
  require_relative "hotswap/middleware"
4
+ require_relative "hotswap/database"
3
5
  require_relative "hotswap/cli"
4
6
  require_relative "hotswap/socket_server"
5
7
  require_relative "hotswap/railtie" if defined?(Rails::Railtie)
@@ -8,11 +10,43 @@ module Hotswap
8
10
  class Error < StandardError; end
9
11
 
10
12
  class << self
11
- attr_accessor :database_path, :socket_path, :stderr_socket_path
13
+ attr_accessor :socket_path, :stderr_socket_path
14
+ attr_writer :logger
15
+
16
+ def logger
17
+ @logger ||= Logger.new($stdout, progname: "hotswap")
18
+ end
12
19
 
13
20
  def configure
14
21
  yield self
15
22
  end
23
+
24
+ # Registry of managed databases
25
+ def databases
26
+ @databases ||= []
27
+ end
28
+
29
+ def register(path)
30
+ db = Database.new(path)
31
+ databases << db unless databases.any? { |d| d.path == db.path }
32
+ db
33
+ end
34
+
35
+ def find_database(path)
36
+ return nil if path == "-"
37
+ resolved = File.expand_path(path)
38
+ databases.find { |db| db.path == resolved }
39
+ end
40
+
41
+ # Backward compat: single database_path getter/setter
42
+ def database_path=(path)
43
+ @databases = []
44
+ register(path) if path
45
+ end
46
+
47
+ def database_path
48
+ databases.first&.path
49
+ end
16
50
  end
17
51
 
18
52
  self.socket_path = "tmp/sockets/hotswap.sock"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotswap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
@@ -160,6 +160,7 @@ files:
160
160
  - exe/hotswap
161
161
  - lib/hotswap.rb
162
162
  - lib/hotswap/cli.rb
163
+ - lib/hotswap/database.rb
163
164
  - lib/hotswap/middleware.rb
164
165
  - lib/hotswap/railtie.rb
165
166
  - lib/hotswap/socket_server.rb