dbwatcher 0.1.5
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 +7 -0
- data/README.md +282 -0
- data/Rakefile +72 -0
- data/app/controllers/dbwatcher/sessions_controller.rb +38 -0
- data/app/views/dbwatcher/sessions/index.html.erb +37 -0
- data/app/views/dbwatcher/sessions/show.html.erb +150 -0
- data/app/views/layouts/dbwatcher/application.html.erb +34 -0
- data/bin/console +11 -0
- data/bin/release +385 -0
- data/bin/setup +8 -0
- data/config/routes.rb +10 -0
- data/lib/dbwatcher/configuration.rb +24 -0
- data/lib/dbwatcher/engine.rb +30 -0
- data/lib/dbwatcher/middleware.rb +40 -0
- data/lib/dbwatcher/model_extension.rb +61 -0
- data/lib/dbwatcher/storage.rb +110 -0
- data/lib/dbwatcher/tracker.rb +112 -0
- data/lib/dbwatcher/version.rb +5 -0
- data/lib/dbwatcher.rb +40 -0
- metadata +198 -0
data/bin/release
ADDED
@@ -0,0 +1,385 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "optparse"
|
5
|
+
require "time"
|
6
|
+
require_relative "../lib/dbwatcher/version"
|
7
|
+
|
8
|
+
# Module for handling changelog generation from git commits
|
9
|
+
# rubocop:disable Metrics/ModuleLength
|
10
|
+
module ChangelogGenerator
|
11
|
+
# Git commit format for parsing
|
12
|
+
COMMIT_FORMAT = "%H|%s|%an|%ad"
|
13
|
+
|
14
|
+
# Commit type categorization for changelog generation
|
15
|
+
COMMIT_CATEGORIES = {
|
16
|
+
"feat" => "Added",
|
17
|
+
"feature" => "Added",
|
18
|
+
"add" => "Added",
|
19
|
+
"fix" => "Fixed",
|
20
|
+
"bugfix" => "Fixed",
|
21
|
+
"bug" => "Fixed",
|
22
|
+
"change" => "Changed",
|
23
|
+
"update" => "Changed",
|
24
|
+
"improve" => "Changed",
|
25
|
+
"refactor" => "Changed",
|
26
|
+
"perf" => "Changed",
|
27
|
+
"remove" => "Removed",
|
28
|
+
"delete" => "Removed",
|
29
|
+
"deprecate" => "Deprecated",
|
30
|
+
"security" => "Security",
|
31
|
+
"docs" => "Documentation",
|
32
|
+
"doc" => "Documentation",
|
33
|
+
"test" => "Testing",
|
34
|
+
"tests" => "Testing",
|
35
|
+
"ci" => "CI/CD",
|
36
|
+
"build" => "Build",
|
37
|
+
"chore" => "Maintenance"
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
# Order of sections in generated changelog
|
41
|
+
CHANGELOG_SECTION_ORDER = [
|
42
|
+
"Added", "Changed", "Fixed", "Deprecated", "Removed",
|
43
|
+
"Security", "Documentation", "Testing", "CI/CD", "Build", "Maintenance"
|
44
|
+
].freeze
|
45
|
+
|
46
|
+
def generate_auto_changelog_entries
|
47
|
+
commits = fetch_commits_for_changelog
|
48
|
+
|
49
|
+
return "\n### Changes\n\n- No significant changes\n" if commits.empty?
|
50
|
+
|
51
|
+
categorized_commits = categorize_commits(commits)
|
52
|
+
generate_changelog_sections(categorized_commits)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def fetch_commits_for_changelog
|
58
|
+
last_tag = get_last_release_tag
|
59
|
+
|
60
|
+
if last_tag
|
61
|
+
puts "📊 Analyzing commits since #{last_tag}..."
|
62
|
+
get_commits_since_tag(last_tag)
|
63
|
+
else
|
64
|
+
puts "📊 No previous release found, analyzing all commits..."
|
65
|
+
get_all_commits
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def get_last_release_tag
|
70
|
+
tags = `git tag -l "v*" --sort=-version:refname`.strip.split("\n")
|
71
|
+
tags.first
|
72
|
+
end
|
73
|
+
|
74
|
+
def get_commits_since_tag(tag)
|
75
|
+
commits_output = `git log #{tag}..HEAD --pretty=format:"#{COMMIT_FORMAT}" --no-merges --date=short`.strip
|
76
|
+
parse_commit_output(commits_output)
|
77
|
+
end
|
78
|
+
|
79
|
+
def get_all_commits
|
80
|
+
commits_output = `git log --pretty=format:"#{COMMIT_FORMAT}" --no-merges --date=short`.strip
|
81
|
+
parse_commit_output(commits_output)
|
82
|
+
end
|
83
|
+
|
84
|
+
def parse_commit_output(commits_output)
|
85
|
+
return [] if commits_output.empty?
|
86
|
+
|
87
|
+
commits_output.split("\n").map do |line|
|
88
|
+
hash, subject, author, date = line.split("|", 4)
|
89
|
+
{
|
90
|
+
hash: hash,
|
91
|
+
subject: subject,
|
92
|
+
author: author,
|
93
|
+
date: date
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def categorize_commits(commits)
|
99
|
+
categories = Hash.new { |hash, key| hash[key] = [] }
|
100
|
+
|
101
|
+
commits.each do |commit|
|
102
|
+
category = determine_commit_category(commit[:subject])
|
103
|
+
clean_message = clean_commit_message(commit[:subject])
|
104
|
+
|
105
|
+
categories[category] << {
|
106
|
+
message: clean_message,
|
107
|
+
hash: commit[:hash][0..7], # Short hash
|
108
|
+
author: commit[:author]
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
categories
|
113
|
+
end
|
114
|
+
|
115
|
+
def determine_commit_category(subject)
|
116
|
+
subject_lower = subject.downcase
|
117
|
+
|
118
|
+
# Check for conventional commit format (type: description)
|
119
|
+
if subject_lower.match(/^(\w+)(\(.+\))?\s*:\s*/)
|
120
|
+
commit_type = ::Regexp.last_match(1).downcase
|
121
|
+
return COMMIT_CATEGORIES[commit_type] || "Changed"
|
122
|
+
end
|
123
|
+
|
124
|
+
# Check for keywords in the commit message
|
125
|
+
COMMIT_CATEGORIES.each do |keyword, category|
|
126
|
+
return category if subject_lower.include?(keyword)
|
127
|
+
end
|
128
|
+
|
129
|
+
"Changed" # Default category
|
130
|
+
end
|
131
|
+
|
132
|
+
def clean_commit_message(message)
|
133
|
+
# Remove conventional commit prefixes
|
134
|
+
cleaned = message.gsub(/^(\w+)(\(.+\))?\s*:\s*/, "")
|
135
|
+
|
136
|
+
return message if cleaned.empty? # Fallback to original if cleaning removes everything
|
137
|
+
|
138
|
+
# Capitalize first letter and remove trailing periods
|
139
|
+
cleaned = capitalize_first_letter(cleaned)
|
140
|
+
cleaned.chomp(".")
|
141
|
+
end
|
142
|
+
|
143
|
+
def capitalize_first_letter(text)
|
144
|
+
return text if text.empty?
|
145
|
+
|
146
|
+
text[0].upcase + text[1..]
|
147
|
+
end
|
148
|
+
|
149
|
+
def generate_changelog_sections(categorized_commits)
|
150
|
+
sections = build_changelog_sections(categorized_commits)
|
151
|
+
|
152
|
+
if sections.empty?
|
153
|
+
default_changelog_section
|
154
|
+
else
|
155
|
+
sections.join("\n")
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def build_changelog_sections(categorized_commits)
|
160
|
+
sections = []
|
161
|
+
|
162
|
+
CHANGELOG_SECTION_ORDER.each do |category|
|
163
|
+
commits = categorized_commits[category]
|
164
|
+
next unless commits&.any?
|
165
|
+
|
166
|
+
sections << "\n### #{category}\n"
|
167
|
+
commits.each { |commit| sections << "- #{commit[:message]}" }
|
168
|
+
sections << ""
|
169
|
+
end
|
170
|
+
|
171
|
+
sections
|
172
|
+
end
|
173
|
+
|
174
|
+
def default_changelog_section
|
175
|
+
"\n### Changes\n- Minor improvements and updates\n"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
# rubocop:enable Metrics/ModuleLength
|
179
|
+
|
180
|
+
# Manages the release process for DB Watcher including version bumping,
|
181
|
+
# changelog generation, and git tagging.
|
182
|
+
class ReleaseManager
|
183
|
+
include ChangelogGenerator
|
184
|
+
|
185
|
+
VALID_TYPES = %w[major minor patch].freeze
|
186
|
+
VERSION_FILE = "lib/dbwatcher/version.rb"
|
187
|
+
CHANGELOG_FILE = "CHANGELOG.md"
|
188
|
+
|
189
|
+
def initialize
|
190
|
+
@options = default_options
|
191
|
+
end
|
192
|
+
|
193
|
+
def run(args)
|
194
|
+
parse_options(args)
|
195
|
+
display_dry_run_warning if @options[:dry_run]
|
196
|
+
|
197
|
+
current_version = Dbwatcher::VERSION
|
198
|
+
new_version = calculate_new_version(current_version, @options[:type])
|
199
|
+
|
200
|
+
display_version_info(current_version, new_version)
|
201
|
+
|
202
|
+
unless @options[:dry_run]
|
203
|
+
update_version_file(new_version)
|
204
|
+
update_changelog(new_version)
|
205
|
+
handle_git_operations(new_version) if @options[:push]
|
206
|
+
end
|
207
|
+
|
208
|
+
display_completion_message(new_version)
|
209
|
+
end
|
210
|
+
|
211
|
+
private
|
212
|
+
|
213
|
+
def default_options
|
214
|
+
{
|
215
|
+
type: "patch",
|
216
|
+
push: false,
|
217
|
+
dry_run: false,
|
218
|
+
auto_changelog: true
|
219
|
+
}
|
220
|
+
end
|
221
|
+
|
222
|
+
def display_dry_run_warning
|
223
|
+
puts "🧪 DRY RUN MODE - No changes will be made"
|
224
|
+
end
|
225
|
+
|
226
|
+
def display_version_info(current_version, new_version)
|
227
|
+
puts "📦 Current version: #{current_version}"
|
228
|
+
puts "🚀 New version: #{new_version}"
|
229
|
+
end
|
230
|
+
|
231
|
+
def display_completion_message(new_version)
|
232
|
+
puts "\n✅ Release preparation complete!"
|
233
|
+
|
234
|
+
if @options[:auto_changelog]
|
235
|
+
display_auto_changelog_next_steps(new_version)
|
236
|
+
else
|
237
|
+
display_manual_changelog_next_steps(new_version)
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def display_auto_changelog_next_steps(new_version)
|
242
|
+
puts "\nNext steps:"
|
243
|
+
puts "1. Review and edit #{CHANGELOG_FILE} (auto-generated entries added)"
|
244
|
+
puts "2. Commit and push changes"
|
245
|
+
puts "3. Create and push tag (v#{new_version})"
|
246
|
+
puts "4. GitHub Actions will automatically publish to RubyGems"
|
247
|
+
end
|
248
|
+
|
249
|
+
def display_manual_changelog_next_steps(new_version)
|
250
|
+
puts "\nNext steps:"
|
251
|
+
puts "1. Add changelog entries to #{CHANGELOG_FILE}"
|
252
|
+
puts "2. Commit and push changes"
|
253
|
+
puts "3. Create and push tag (v#{new_version})"
|
254
|
+
puts "4. GitHub Actions will automatically publish to RubyGems"
|
255
|
+
end
|
256
|
+
|
257
|
+
def handle_git_operations(new_version)
|
258
|
+
create_and_push_tag(new_version)
|
259
|
+
rescue StandardError => e
|
260
|
+
puts "❌ Error during git operations: #{e.message}"
|
261
|
+
exit 1
|
262
|
+
end
|
263
|
+
|
264
|
+
def parse_options(args)
|
265
|
+
OptionParser.new do |opts|
|
266
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
|
267
|
+
|
268
|
+
opts.on("-t", "--type TYPE", VALID_TYPES,
|
269
|
+
"Release type: #{VALID_TYPES.join(", ")} (default: patch)") do |type|
|
270
|
+
@options[:type] = type
|
271
|
+
end
|
272
|
+
|
273
|
+
opts.on("-p", "--push", "Automatically commit, tag, and push") do
|
274
|
+
@options[:push] = true
|
275
|
+
end
|
276
|
+
|
277
|
+
opts.on("-n", "--dry-run", "Show what would be done without making changes") do
|
278
|
+
@options[:dry_run] = true
|
279
|
+
end
|
280
|
+
|
281
|
+
opts.on("--[no-]auto-changelog", "Auto-generate changelog from git commits (default: true)") do |auto|
|
282
|
+
@options[:auto_changelog] = auto
|
283
|
+
end
|
284
|
+
|
285
|
+
opts.on("-h", "--help", "Show this help") do
|
286
|
+
puts opts
|
287
|
+
exit
|
288
|
+
end
|
289
|
+
end.parse!(args)
|
290
|
+
end
|
291
|
+
|
292
|
+
def calculate_new_version(version, type)
|
293
|
+
major, minor, patch = version.split(".").map(&:to_i)
|
294
|
+
|
295
|
+
case type
|
296
|
+
when "major"
|
297
|
+
"#{major + 1}.0.0"
|
298
|
+
when "minor"
|
299
|
+
"#{major}.#{minor + 1}.0"
|
300
|
+
when "patch"
|
301
|
+
"#{major}.#{minor}.#{patch + 1}"
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
def update_version_file(new_version)
|
306
|
+
content = File.read(VERSION_FILE)
|
307
|
+
updated_content = content.gsub(/VERSION = "[^"]*"/, "VERSION = \"#{new_version}\"")
|
308
|
+
|
309
|
+
File.write(VERSION_FILE, updated_content)
|
310
|
+
puts "📝 Updated #{VERSION_FILE}"
|
311
|
+
rescue StandardError => e
|
312
|
+
puts "❌ Error updating version file: #{e.message}"
|
313
|
+
exit 1
|
314
|
+
end
|
315
|
+
|
316
|
+
def update_changelog(new_version)
|
317
|
+
return unless File.exist?(CHANGELOG_FILE)
|
318
|
+
|
319
|
+
puts "\n📋 Generating changelog from git commits..." if @options[:auto_changelog]
|
320
|
+
|
321
|
+
content = File.read(CHANGELOG_FILE)
|
322
|
+
changelog_entries = generate_changelog_content
|
323
|
+
updated_content = insert_changelog_entries(content, new_version, changelog_entries)
|
324
|
+
|
325
|
+
File.write(CHANGELOG_FILE, updated_content)
|
326
|
+
puts "📝 Updated #{CHANGELOG_FILE}"
|
327
|
+
|
328
|
+
display_changelog_success_message if @options[:auto_changelog] && !@options[:dry_run]
|
329
|
+
rescue StandardError => e
|
330
|
+
puts "❌ Error updating changelog: #{e.message}"
|
331
|
+
exit 1
|
332
|
+
end
|
333
|
+
|
334
|
+
def generate_changelog_content
|
335
|
+
if @options[:auto_changelog]
|
336
|
+
generate_auto_changelog_entries
|
337
|
+
else
|
338
|
+
"\n### Changes\n\n- TODO: Add changelog entries\n"
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
def insert_changelog_entries(content, new_version, changelog_entries)
|
343
|
+
today = Time.now.strftime("%Y-%m-%d")
|
344
|
+
|
345
|
+
content.gsub(
|
346
|
+
"## [Unreleased]",
|
347
|
+
"## [Unreleased]\n\n## [#{new_version}] - #{today}#{changelog_entries}"
|
348
|
+
)
|
349
|
+
end
|
350
|
+
|
351
|
+
def display_changelog_success_message
|
352
|
+
puts "📋 Generated changelog entries from git commits"
|
353
|
+
puts " Please review and edit the changelog before committing"
|
354
|
+
end
|
355
|
+
|
356
|
+
def create_and_push_tag(new_version)
|
357
|
+
return display_manual_git_instructions(new_version) unless @options[:push]
|
358
|
+
|
359
|
+
puts "\n🏷️ Creating and pushing tag..."
|
360
|
+
execute_git_commands(new_version)
|
361
|
+
puts "✅ Tag v#{new_version} created and pushed"
|
362
|
+
end
|
363
|
+
|
364
|
+
def display_manual_git_instructions(new_version)
|
365
|
+
puts "\n🏷️ To create and push the release tag, run:"
|
366
|
+
puts " git add -A && git commit -m 'Release v#{new_version}' && git tag v#{new_version} && " \
|
367
|
+
"git push origin master && git push origin v#{new_version}"
|
368
|
+
end
|
369
|
+
|
370
|
+
def execute_git_commands(new_version)
|
371
|
+
commands = [
|
372
|
+
"git add -A",
|
373
|
+
"git commit -m 'Release v#{new_version}'",
|
374
|
+
"git tag v#{new_version}",
|
375
|
+
"git push origin master",
|
376
|
+
"git push origin v#{new_version}"
|
377
|
+
]
|
378
|
+
|
379
|
+
commands.each do |command|
|
380
|
+
raise "Failed to execute: #{command}" unless system(command)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
|
385
|
+
ReleaseManager.new.run(ARGV) if __FILE__ == $PROGRAM_NAME
|
data/bin/setup
ADDED
data/config/routes.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :storage_path, :enabled, :max_sessions, :auto_clean_after_days
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@storage_path = default_storage_path
|
9
|
+
@enabled = true
|
10
|
+
@max_sessions = 100
|
11
|
+
@auto_clean_after_days = 7
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def default_storage_path
|
17
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
18
|
+
Rails.root.join("tmp", "dbwatcher").to_s
|
19
|
+
else
|
20
|
+
File.join(Dir.pwd, "tmp", "dbwatcher")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace Dbwatcher
|
6
|
+
|
7
|
+
initializer "dbwatcher.setup" do |app|
|
8
|
+
# Auto-include in all models
|
9
|
+
ActiveSupport.on_load(:active_record) do
|
10
|
+
include Dbwatcher::ModelExtension
|
11
|
+
end
|
12
|
+
|
13
|
+
# Add middleware
|
14
|
+
app.middleware.use Dbwatcher::Middleware
|
15
|
+
end
|
16
|
+
|
17
|
+
# Mount the engine routes automatically
|
18
|
+
initializer "dbwatcher.routes", after: :add_routing_paths do |app|
|
19
|
+
app.routes.append do
|
20
|
+
mount Dbwatcher::Engine => "/dbwatcher", as: :dbwatcher
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Serve static assets
|
25
|
+
initializer "dbwatcher.assets" do |app|
|
26
|
+
app.config.assets.paths << root.join("app", "assets", "stylesheets")
|
27
|
+
app.config.assets.paths << root.join("app", "assets", "javascripts")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
class Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(env)
|
10
|
+
if should_track?(env)
|
11
|
+
Dbwatcher.track(
|
12
|
+
name: "HTTP #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}",
|
13
|
+
metadata: build_metadata(env)
|
14
|
+
) do
|
15
|
+
@app.call(env)
|
16
|
+
end
|
17
|
+
else
|
18
|
+
@app.call(env)
|
19
|
+
end
|
20
|
+
rescue StandardError => e
|
21
|
+
warn "Dbwatcher middleware error: #{e.message}"
|
22
|
+
@app.call(env)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def should_track?(env)
|
28
|
+
env["QUERY_STRING"]&.include?("dbwatch=true") || false
|
29
|
+
end
|
30
|
+
|
31
|
+
def build_metadata(env)
|
32
|
+
{
|
33
|
+
ip: env["REMOTE_ADDR"],
|
34
|
+
user_agent: env["HTTP_USER_AGENT"],
|
35
|
+
path: env["PATH_INFO"],
|
36
|
+
method: env["REQUEST_METHOD"]
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
module ModelExtension
|
5
|
+
def self.included(base)
|
6
|
+
extend ActiveSupport::Concern if defined?(ActiveSupport)
|
7
|
+
|
8
|
+
base.class_eval do
|
9
|
+
after_create :dbwatcher_track_create if respond_to?(:after_create)
|
10
|
+
after_update :dbwatcher_track_update if respond_to?(:after_update)
|
11
|
+
after_destroy :dbwatcher_track_destroy if respond_to?(:after_destroy)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def dbwatcher_track_create
|
18
|
+
track_database_change(
|
19
|
+
operation: "INSERT",
|
20
|
+
changes: attributes.map { |column, value| build_change(column, nil, value) }
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
def dbwatcher_track_update
|
25
|
+
return unless saved_changes.any?
|
26
|
+
|
27
|
+
track_database_change(
|
28
|
+
operation: "UPDATE",
|
29
|
+
changes: saved_changes.except("updated_at").map do |column, (old_val, new_val)|
|
30
|
+
build_change(column, old_val, new_val)
|
31
|
+
end
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def dbwatcher_track_destroy
|
36
|
+
track_database_change(
|
37
|
+
operation: "DELETE",
|
38
|
+
changes: attributes.map { |column, value| build_change(column, value, nil) }
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
def track_database_change(operation:, changes:)
|
43
|
+
Dbwatcher::Tracker.record_change({
|
44
|
+
table_name: self.class.table_name,
|
45
|
+
record_id: id,
|
46
|
+
operation: operation,
|
47
|
+
timestamp: Time.now.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
48
|
+
changes: changes,
|
49
|
+
record_snapshot: attributes
|
50
|
+
})
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_change(column, old_value, new_value)
|
54
|
+
{
|
55
|
+
column: column,
|
56
|
+
old_value: old_value&.to_s,
|
57
|
+
new_value: new_value&.to_s
|
58
|
+
}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbwatcher
|
4
|
+
class Storage
|
5
|
+
class << self
|
6
|
+
def save_session(session)
|
7
|
+
return unless session&.id
|
8
|
+
|
9
|
+
ensure_storage_directory
|
10
|
+
|
11
|
+
# Save individual session file
|
12
|
+
session_file = File.join(sessions_path, "#{session.id}.json")
|
13
|
+
File.write(session_file, JSON.pretty_generate(session.to_h))
|
14
|
+
|
15
|
+
# Update index
|
16
|
+
update_index(session)
|
17
|
+
|
18
|
+
# Clean old sessions if needed
|
19
|
+
cleanup_old_sessions
|
20
|
+
rescue StandardError => e
|
21
|
+
warn "Failed to save session #{session&.id}: #{e.message}"
|
22
|
+
end
|
23
|
+
|
24
|
+
def load_session(id)
|
25
|
+
return nil if id.nil? || id.empty?
|
26
|
+
|
27
|
+
session_file = File.join(sessions_path, "#{id}.json")
|
28
|
+
return nil unless File.exist?(session_file)
|
29
|
+
|
30
|
+
data = JSON.parse(File.read(session_file), symbolize_names: true)
|
31
|
+
Tracker::Session.new(data)
|
32
|
+
rescue JSON::ParserError => e
|
33
|
+
warn "Failed to parse session file #{id}: #{e.message}"
|
34
|
+
nil
|
35
|
+
rescue StandardError => e
|
36
|
+
warn "Failed to load session #{id}: #{e.message}"
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def all_sessions
|
41
|
+
index_file = File.join(storage_path, "index.json")
|
42
|
+
return [] unless File.exist?(index_file)
|
43
|
+
|
44
|
+
JSON.parse(File.read(index_file), symbolize_names: true)
|
45
|
+
rescue JSON::ParserError => e
|
46
|
+
warn "Failed to parse sessions index: #{e.message}"
|
47
|
+
[]
|
48
|
+
rescue StandardError => e
|
49
|
+
warn "Failed to load sessions: #{e.message}"
|
50
|
+
[]
|
51
|
+
end
|
52
|
+
|
53
|
+
def reset!
|
54
|
+
FileUtils.rm_rf(storage_path)
|
55
|
+
ensure_storage_directory
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def storage_path
|
61
|
+
Dbwatcher.configuration.storage_path
|
62
|
+
end
|
63
|
+
|
64
|
+
def sessions_path
|
65
|
+
File.join(storage_path, "sessions")
|
66
|
+
end
|
67
|
+
|
68
|
+
def ensure_storage_directory
|
69
|
+
FileUtils.mkdir_p(sessions_path)
|
70
|
+
|
71
|
+
# Create index if it doesn't exist
|
72
|
+
index_file = File.join(storage_path, "index.json")
|
73
|
+
File.write(index_file, "[]") unless File.exist?(index_file)
|
74
|
+
end
|
75
|
+
|
76
|
+
def update_index(session)
|
77
|
+
index_file = File.join(storage_path, "index.json")
|
78
|
+
index = JSON.parse(File.read(index_file), symbolize_names: true)
|
79
|
+
|
80
|
+
# Add new session summary to index
|
81
|
+
index.unshift({
|
82
|
+
id: session.id,
|
83
|
+
name: session.name,
|
84
|
+
started_at: session.started_at,
|
85
|
+
ended_at: session.ended_at,
|
86
|
+
change_count: session.changes.count
|
87
|
+
})
|
88
|
+
|
89
|
+
# Keep only max_sessions
|
90
|
+
index = index.first(Dbwatcher.configuration.max_sessions)
|
91
|
+
|
92
|
+
File.write(index_file, JSON.pretty_generate(index))
|
93
|
+
rescue StandardError => e
|
94
|
+
warn "Failed to update sessions index: #{e.message}"
|
95
|
+
end
|
96
|
+
|
97
|
+
def cleanup_old_sessions
|
98
|
+
return unless Dbwatcher.configuration.auto_clean_after_days
|
99
|
+
|
100
|
+
cutoff_date = Time.now - (Dbwatcher.configuration.auto_clean_after_days * 24 * 60 * 60)
|
101
|
+
|
102
|
+
Dir.glob(File.join(sessions_path, "*.json")).each do |file|
|
103
|
+
File.delete(file) if File.mtime(file) < cutoff_date
|
104
|
+
end
|
105
|
+
rescue StandardError => e
|
106
|
+
warn "Failed to cleanup old sessions: #{e.message}"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|