hotswap 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4648f76060073d16f095250ed5e638c4150d5094069e839b2bce9b1b471852a
4
+ data.tar.gz: 592b37ce1c1f4691e9bb74d9606ef7727333a3b65f878ebff906084b9a56168e
5
+ SHA512:
6
+ metadata.gz: 869189ee05937fab2b970e31b0cbd13c6a0f36d4d127b0dfb664c793ffd1b060d07050ad0563c1cb224c68187bfa178000305c2cb4e85dad1f0e336d842b69ec
7
+ data.tar.gz: 94f141f2bd7990c6a55a53b01c1491941179453361a014caf91dd2cb83f890026e9898e78b19a8e16f8a8890c175cc298efe1145616900a2eeb9149ffaa503c4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brad Gessler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/exe/hotswap ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+
6
+ socket_path = ENV.fetch("HOTSWAP_SOCKET", "tmp/sqlite3.sock")
7
+ stderr_socket_path = ENV.fetch("HOTSWAP_STDERR_SOCKET", "tmp/sqlite3.stderr.sock")
8
+
9
+ unless File.exist?(socket_path)
10
+ $stderr.puts "ERROR: socket not found at #{socket_path}"
11
+ exit 1
12
+ end
13
+
14
+ # Connect stderr socket first (if available) so it's registered before the command runs
15
+ stderr_sock = nil
16
+ stderr_key = SecureRandom.hex(8)
17
+ if File.exist?(stderr_socket_path)
18
+ stderr_sock = UNIXSocket.new(stderr_socket_path)
19
+ stderr_sock.write("#{stderr_key}\n")
20
+ end
21
+
22
+ sock = UNIXSocket.new(socket_path)
23
+
24
+ # Send command args with stderr key as first line
25
+ args = ARGV.join(" ")
26
+ args += " --stderr-key=#{stderr_key}" if stderr_sock
27
+ sock.write(args + "\n")
28
+
29
+ # Pipe stdin to socket (in background thread)
30
+ writer = Thread.new do
31
+ IO.copy_stream($stdin, sock) rescue nil
32
+ sock.close_write rescue nil
33
+ end
34
+
35
+ # Pipe stderr socket to local stderr (in background thread)
36
+ stderr_reader = nil
37
+ if stderr_sock
38
+ stderr_reader = Thread.new do
39
+ IO.copy_stream(stderr_sock, $stderr) rescue nil
40
+ end
41
+ end
42
+
43
+ # Pipe socket output to stdout
44
+ IO.copy_stream(sock, $stdout) rescue nil
45
+
46
+ writer.join
47
+ stderr_reader&.join
48
+ stderr_sock&.close rescue nil
49
+ sock.close rescue nil
@@ -0,0 +1,132 @@
1
+ require "thor"
2
+ require "tempfile"
3
+ require "sqlite3"
4
+
5
+ module Hotswap
6
+ class CLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ desc "cp SRC DST", "Copy a database to/from the running server. Use 'database' to refer to the running database."
12
+ long_desc <<~DESC
13
+ Copy a SQLite database to or from the running server.
14
+
15
+ Use 'database' as a placeholder for the running server's database.
16
+ Use '-' for stdin/stdout.
17
+
18
+ Examples:
19
+
20
+ hotswap cp ./new.sqlite3 database # replace the running database
21
+ hotswap cp database ./backup.sqlite3 # snapshot the running database
22
+ hotswap cp - database # read from stdin
23
+ hotswap cp database - # write to stdout
24
+ DESC
25
+ def cp(src, dst)
26
+ db_path = Hotswap.database_path
27
+ unless db_path
28
+ $stderr.write("ERROR: database_path not configured\n")
29
+ return
30
+ end
31
+
32
+ if src == "database" && dst == "database"
33
+ $stderr.write("ERROR: source and destination can't both be 'database'\n")
34
+ return
35
+ end
36
+
37
+ if dst == "database"
38
+ push_database(src, db_path)
39
+ elsif src == "database"
40
+ pull_database(dst, db_path)
41
+ else
42
+ $stderr.write("ERROR: one of src/dst must be 'database'\n")
43
+ end
44
+ end
45
+
46
+ desc "push", "Replace the running database from stdin"
47
+ def push
48
+ cp("-", "database")
49
+ end
50
+
51
+ desc "pull", "Snapshot the running database to stdout"
52
+ def pull
53
+ cp("database", "-")
54
+ end
55
+
56
+ desc "version", "Print the hotswap version"
57
+ def version
58
+ $stdout.write("hotswap #{Hotswap::VERSION}\n")
59
+ end
60
+
61
+ private
62
+
63
+ def push_database(src, db_path)
64
+ input = (src == "-") ? $stdin : File.open(src, "rb")
65
+
66
+ dir = File.dirname(db_path)
67
+ temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
68
+ begin
69
+ IO.copy_stream(input, temp)
70
+ temp.close
71
+
72
+ db = SQLite3::Database.new(temp.path)
73
+ result = db.execute("PRAGMA integrity_check")
74
+ db.close
75
+ unless result == [["ok"]]
76
+ $stderr.write("ERROR: integrity check failed\n")
77
+ return
78
+ end
79
+
80
+ $stderr.write("Swapping database...\n")
81
+
82
+ Middleware::SWAP_LOCK.synchronize do
83
+ if defined?(ActiveRecord::Base)
84
+ ActiveRecord::Base.connection_handler.clear_all_connections!
85
+ end
86
+ File.rename(temp.path, db_path)
87
+ end
88
+
89
+ if defined?(ActiveRecord::Base)
90
+ ActiveRecord::Base.establish_connection
91
+ end
92
+
93
+ $stdout.write("OK\n")
94
+ rescue => e
95
+ $stderr.write("ERROR: #{e.message}\n")
96
+ ensure
97
+ input.close if input.is_a?(File)
98
+ temp.unlink if temp && File.exist?(temp.path)
99
+ end
100
+ end
101
+
102
+ def pull_database(dst, db_path)
103
+ unless File.exist?(db_path)
104
+ $stderr.write("ERROR: database file not found\n")
105
+ return
106
+ end
107
+
108
+ dir = File.dirname(db_path)
109
+ temp = Tempfile.new(["hotswap-pull", ".sqlite3"], dir)
110
+ begin
111
+ src_db = SQLite3::Database.new(db_path)
112
+ dst_db = SQLite3::Database.new(temp.path)
113
+ b = SQLite3::Backup.new(dst_db, "main", src_db, "main")
114
+ b.step(-1)
115
+ b.finish
116
+ dst_db.close
117
+ src_db.close
118
+
119
+ if dst == "-"
120
+ File.open(temp.path, "rb") { |f| IO.copy_stream(f, $stdout) }
121
+ else
122
+ FileUtils.cp(temp.path, dst)
123
+ $stderr.write("OK\n")
124
+ end
125
+ rescue => e
126
+ $stderr.write("ERROR: #{e.message}\n")
127
+ ensure
128
+ temp.unlink if temp && File.exist?(temp.path)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,13 @@
1
+ module Hotswap
2
+ class Middleware
3
+ SWAP_LOCK = Mutex.new
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ SWAP_LOCK.synchronize { @app.call(env) }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,41 @@
1
+ module Hotswap
2
+ class Railtie < Rails::Railtie
3
+ config.hotswap = ActiveSupport::OrderedOptions.new
4
+
5
+ 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
9
+ 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"]
13
+ end
14
+ end
15
+
16
+ # Default socket paths
17
+ if app.config.hotswap.socket_path
18
+ Hotswap.socket_path = app.config.hotswap.socket_path
19
+ else
20
+ Hotswap.socket_path = File.join(app.root, "tmp", "sqlite3.sock")
21
+ end
22
+
23
+ if app.config.hotswap.stderr_socket_path
24
+ Hotswap.stderr_socket_path = app.config.hotswap.stderr_socket_path
25
+ else
26
+ Hotswap.stderr_socket_path = File.join(app.root, "tmp", "sqlite3.stderr.sock")
27
+ end
28
+ end
29
+
30
+ initializer "hotswap.middleware" do |app|
31
+ app.middleware.use Hotswap::Middleware
32
+ end
33
+
34
+ initializer "hotswap.socket_server" do
35
+ server = Hotswap::SocketServer.new
36
+ server.start
37
+
38
+ at_exit { server.stop }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,127 @@
1
+ require "socket"
2
+
3
+ module Hotswap
4
+ class SocketServer
5
+ attr_reader :socket_path, :stderr_socket_path
6
+
7
+ def initialize(socket_path: Hotswap.socket_path, stderr_socket_path: Hotswap.stderr_socket_path)
8
+ @socket_path = socket_path
9
+ @stderr_socket_path = stderr_socket_path
10
+ @server = nil
11
+ @stderr_server = nil
12
+ @thread = nil
13
+ @stderr_clients = {}
14
+ @stderr_mutex = Mutex.new
15
+ end
16
+
17
+ def start
18
+ cleanup_stale_socket(@socket_path)
19
+ cleanup_stale_socket(@stderr_socket_path)
20
+
21
+ @server = UNIXServer.new(@socket_path)
22
+ @stderr_server = UNIXServer.new(@stderr_socket_path)
23
+
24
+ @thread = Thread.new { accept_loop }
25
+ @thread.report_on_exception = false
26
+ self
27
+ end
28
+
29
+ def stop
30
+ @server&.close
31
+ @stderr_server&.close
32
+ @thread&.kill
33
+ @stderr_mutex.synchronize { @stderr_clients.each_value(&:close) rescue nil }
34
+ [@socket_path, @stderr_socket_path].each do |path|
35
+ File.delete(path) if path && File.exist?(path)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def accept_loop
42
+ ios = [@server, @stderr_server]
43
+ loop do
44
+ readable, = IO.select(ios)
45
+ readable.each do |server|
46
+ client = server.accept
47
+ if server == @stderr_server
48
+ register_stderr_client(client)
49
+ else
50
+ Thread.new(client) { |sock| handle_connection(sock) }
51
+ end
52
+ end
53
+ end
54
+ rescue IOError, Errno::EBADF
55
+ # Server was closed, exit gracefully
56
+ end
57
+
58
+ def register_stderr_client(client)
59
+ # stderr client sends its PID on first line so we can match it
60
+ line = client.gets
61
+ return client.close unless line
62
+ key = line.strip
63
+ @stderr_mutex.synchronize { @stderr_clients[key] = client }
64
+ end
65
+
66
+ def take_stderr_client(key)
67
+ @stderr_mutex.synchronize { @stderr_clients.delete(key) }
68
+ end
69
+
70
+ def handle_connection(socket)
71
+ # First line is the command args
72
+ line = socket.gets
73
+ return unless line
74
+
75
+ parts = line.strip.split(/\s+/)
76
+ # Last part may be --stderr-key=<key>
77
+ stderr_key = nil
78
+ parts.reject! do |p|
79
+ if p.start_with?("--stderr-key=")
80
+ stderr_key = p.split("=", 2).last
81
+ true
82
+ end
83
+ end
84
+
85
+ # Wait briefly for the stderr client to connect
86
+ stderr_io = nil
87
+ if stderr_key
88
+ 5.times do
89
+ stderr_io = take_stderr_client(stderr_key)
90
+ break if stderr_io
91
+ sleep 0.01
92
+ end
93
+ end
94
+
95
+ # Wire the socket as stdin/stdout and stderr socket as stderr for the CLI
96
+ old_stdin = $stdin
97
+ old_stdout = $stdout
98
+ old_stderr = $stderr
99
+ begin
100
+ $stdin = socket
101
+ $stdout = socket
102
+ $stderr = stderr_io || $stderr
103
+ Hotswap::CLI.start(parts)
104
+ ensure
105
+ $stdin = old_stdin
106
+ $stdout = old_stdout
107
+ $stderr = old_stderr
108
+ end
109
+ rescue => e
110
+ socket.write("ERROR: #{e.message}\n") rescue nil
111
+ ensure
112
+ stderr_io&.close rescue nil
113
+ socket.close rescue nil
114
+ end
115
+
116
+ def cleanup_stale_socket(path)
117
+ return unless File.exist?(path)
118
+ begin
119
+ test = UNIXSocket.new(path)
120
+ test.close
121
+ raise Hotswap::Error, "Socket #{path} is already in use"
122
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
123
+ File.delete(path)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,3 @@
1
+ module Hotswap
2
+ VERSION = "0.1.0"
3
+ end
data/lib/hotswap.rb ADDED
@@ -0,0 +1,20 @@
1
+ require_relative "hotswap/version"
2
+ require_relative "hotswap/middleware"
3
+ require_relative "hotswap/cli"
4
+ require_relative "hotswap/socket_server"
5
+ require_relative "hotswap/railtie" if defined?(Rails::Railtie)
6
+
7
+ module Hotswap
8
+ class Error < StandardError; end
9
+
10
+ class << self
11
+ attr_accessor :database_path, :socket_path, :stderr_socket_path
12
+
13
+ def configure
14
+ yield self
15
+ end
16
+ end
17
+
18
+ self.socket_path = "tmp/sqlite3.sock"
19
+ self.stderr_socket_path = "tmp/sqlite3.stderr.sock"
20
+ end
metadata ADDED
@@ -0,0 +1,187 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hotswap
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brad Gessler
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-02-27 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rack-test
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: railties
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: activerecord
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: actionpack
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: webrick
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ description: Swap a SQLite database on a running Rails server without restart. Requests
153
+ queue briefly during the swap, then resume on the new database.
154
+ executables:
155
+ - hotswap
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - LICENSE
160
+ - exe/hotswap
161
+ - lib/hotswap.rb
162
+ - lib/hotswap/cli.rb
163
+ - lib/hotswap/middleware.rb
164
+ - lib/hotswap/railtie.rb
165
+ - lib/hotswap/socket_server.rb
166
+ - lib/hotswap/version.rb
167
+ licenses:
168
+ - MIT
169
+ metadata: {}
170
+ rdoc_options: []
171
+ require_paths:
172
+ - lib
173
+ required_ruby_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: '3.0'
178
+ required_rubygems_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '0'
183
+ requirements: []
184
+ rubygems_version: 3.6.2
185
+ specification_version: 4
186
+ summary: Hot-swap SQLite databases on a running Rails server
187
+ test_files: []