dbwatcher 0.1.5 → 1.0.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/controllers/dbwatcher/base_controller.rb +95 -0
  4. data/app/controllers/dbwatcher/dashboard_controller.rb +12 -0
  5. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  6. data/app/controllers/dbwatcher/sessions_controller.rb +15 -20
  7. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  8. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  9. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  10. data/app/helpers/dbwatcher/session_helper.rb +27 -0
  11. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  12. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  13. data/app/views/dbwatcher/sessions/index.html.erb +120 -27
  14. data/app/views/dbwatcher/sessions/show.html.erb +326 -129
  15. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  16. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  17. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  18. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  19. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  20. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  21. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  22. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  23. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  24. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  25. data/app/views/layouts/dbwatcher/application.html.erb +375 -26
  26. data/config/routes.rb +17 -3
  27. data/lib/dbwatcher/configuration.rb +9 -1
  28. data/lib/dbwatcher/engine.rb +12 -7
  29. data/lib/dbwatcher/logging.rb +72 -0
  30. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  31. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  32. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  33. data/lib/dbwatcher/sql_logger.rb +107 -0
  34. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  35. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +172 -0
  36. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  37. data/lib/dbwatcher/storage/api/session_api.rb +134 -0
  38. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  39. data/lib/dbwatcher/storage/base_storage.rb +113 -0
  40. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  41. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  42. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  43. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  44. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  45. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  46. data/lib/dbwatcher/storage/errors.rb +86 -0
  47. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  48. data/lib/dbwatcher/storage/null_session.rb +39 -0
  49. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  50. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  51. data/lib/dbwatcher/storage/session.rb +58 -0
  52. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  53. data/lib/dbwatcher/storage/session_query.rb +71 -0
  54. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  55. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  56. data/lib/dbwatcher/storage.rb +112 -85
  57. data/lib/dbwatcher/tracker.rb +4 -55
  58. data/lib/dbwatcher/version.rb +1 -1
  59. data/lib/dbwatcher.rb +12 -2
  60. metadata +47 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_api"
4
+
5
+ module Dbwatcher
6
+ module Storage
7
+ module Api
8
+ class QueryAPI < BaseAPI
9
+ # Filter queries by date
10
+ #
11
+ # @param date [Date, String] date to filter by
12
+ # @return [QueryAPI] self for method chaining
13
+ def for_date(date)
14
+ @date = date.is_a?(String) ? date : date.strftime("%Y-%m-%d")
15
+ self
16
+ end
17
+
18
+ # Filter to slow queries only
19
+ #
20
+ # @param threshold [Integer] duration threshold in milliseconds
21
+ # @return [QueryAPI] self for method chaining
22
+ def slow_only(threshold: 100)
23
+ filters[:slow_threshold] = threshold
24
+ self
25
+ end
26
+
27
+ # Filter queries by table name
28
+ #
29
+ # @param table_name [String] table name to filter by
30
+ # @return [QueryAPI] self for method chaining
31
+ def by_table(table_name)
32
+ filters[:table_name] = table_name
33
+ self
34
+ end
35
+
36
+ # Filter queries between dates
37
+ #
38
+ # @param start_date [Date, String] start date
39
+ # @param end_date [Date, String] end date
40
+ # @return [QueryAPI] self for method chaining
41
+ def between(start_date, end_date)
42
+ start_str = start_date.is_a?(String) ? start_date : start_date.strftime("%Y-%m-%d")
43
+ end_str = end_date.is_a?(String) ? end_date : end_date.strftime("%Y-%m-%d")
44
+ @date_range = Date.parse(start_str)..Date.parse(end_str)
45
+ self
46
+ end
47
+
48
+ # Get all filtered queries
49
+ #
50
+ # @return [Array<Hash>] filtered queries
51
+ def all
52
+ queries = fetch_queries
53
+ apply_filters(queries)
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_queries
59
+ if @date_range
60
+ @date_range.map { |date| storage.load_for_date(date.strftime("%Y-%m-%d")) }.flatten
61
+ elsif @date
62
+ storage.load_for_date(@date)
63
+ else
64
+ recent_queries
65
+ end
66
+ end
67
+
68
+ def apply_filters(queries)
69
+ result = queries
70
+
71
+ # Apply slow threshold filter using symbols only
72
+ if filters[:slow_threshold]
73
+ result = result.select do |q|
74
+ duration = safe_extract(q, :duration)
75
+ duration && duration > filters[:slow_threshold]
76
+ end
77
+ end
78
+
79
+ # Apply table name filter using symbols only
80
+ result = result.select { |q| safe_extract(q, :table_name) == filters[:table_name] } if filters[:table_name]
81
+
82
+ # Apply common filters
83
+ apply_common_filters(result)
84
+ end
85
+
86
+ def recent_queries
87
+ (0..6).map do |days_ago|
88
+ date = (Date.today - days_ago).strftime("%Y-%m-%d")
89
+ storage.load_for_date(date)
90
+ end.flatten
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "base_api"
5
+ require_relative "concerns/table_analyzer"
6
+
7
+ module Dbwatcher
8
+ module Storage
9
+ module Api
10
+ class SessionAPI < BaseAPI
11
+ # Include table analysis capabilities for API layer
12
+ include Api::Concerns::TableAnalyzer
13
+
14
+ # Find a specific session by ID
15
+ #
16
+ # @param id [String] the session ID
17
+ # @return [Session, nil] the session object or nil if not found
18
+ def find(id)
19
+ storage.load(id)
20
+ end
21
+
22
+ # Get all sessions
23
+ #
24
+ # @return [Array<Hash>] array of session data
25
+ def all
26
+ apply_filters(storage.all)
27
+ end
28
+
29
+ # Filter to recent sessions
30
+ #
31
+ # @param days [Integer] number of days back to include
32
+ # @return [SessionAPI] self for method chaining
33
+ def recent(days: 7)
34
+ cutoff = Time.now - (days * 24 * 60 * 60)
35
+ where(started_after: cutoff)
36
+ end
37
+
38
+ # Filter sessions that have changes
39
+ #
40
+ # @return [SessionAPI] self for method chaining
41
+ def with_changes
42
+ filters[:has_changes] = true
43
+ self
44
+ end
45
+
46
+ # Filter sessions by status
47
+ #
48
+ # @param status [String, Symbol] session status (e.g., :active, :completed)
49
+ # @return [SessionAPI] self for method chaining
50
+ def by_status(status)
51
+ filters[:status] = status.to_s
52
+ self
53
+ end
54
+
55
+ # Filter sessions by name pattern
56
+ #
57
+ # @param pattern [String] name pattern to match
58
+ # @return [SessionAPI] self for method chaining
59
+ def by_name(pattern)
60
+ filters[:name_pattern] = pattern
61
+ self
62
+ end
63
+
64
+ # Get sessions with table analysis
65
+ #
66
+ # @return [Array<Hash>] sessions with analyzed table data
67
+ def with_table_analysis
68
+ all.map do |session_info|
69
+ session = find(safe_extract(session_info, :id))
70
+ next session_info unless session
71
+
72
+ session_info.merge(
73
+ tables_summary: build_tables_summary(session)
74
+ )
75
+ end.compact
76
+ end
77
+
78
+ # Get the most active sessions (by change count)
79
+ #
80
+ # @param limit [Integer] maximum number of sessions to return
81
+ # @return [Array<Hash>] sessions ordered by activity
82
+ def most_active(limit: 10)
83
+ sessions_with_counts = all.map do |session_info|
84
+ session = find(safe_extract(session_info, :id))
85
+ change_count = session ? session.changes.length : 0
86
+ session_info.merge(change_count: change_count)
87
+ end
88
+
89
+ sessions_with_counts
90
+ .sort_by { |s| -s[:change_count] }
91
+ .first(limit)
92
+ end
93
+
94
+ private
95
+
96
+ def apply_filters(sessions)
97
+ sessions
98
+ .then { |s| filter_by_start_time(s) }
99
+ .then { |s| filter_by_status(s) }
100
+ .then { |s| filter_by_name_pattern(s) }
101
+ .then { |s| filter_by_changes(s) }
102
+ .then { |s| apply_common_filters(s) }
103
+ end
104
+
105
+ def filter_by_start_time(sessions)
106
+ return sessions unless filters[:started_after]
107
+
108
+ apply_time_filter(sessions, :started_at)
109
+ end
110
+
111
+ def filter_by_status(sessions)
112
+ return sessions unless filters[:status]
113
+
114
+ sessions.select { |s| safe_extract(s, :status) == filters[:status] }
115
+ end
116
+
117
+ def filter_by_name_pattern(sessions)
118
+ return sessions unless filters[:name_pattern]
119
+
120
+ apply_pattern_filter(sessions, %i[name id], filters[:name_pattern])
121
+ end
122
+
123
+ def filter_by_changes(sessions)
124
+ return sessions unless filters[:has_changes]
125
+
126
+ sessions.select do |s|
127
+ session = find(safe_extract(s, :id))
128
+ session&.changes&.any?
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_api"
4
+
5
+ module Dbwatcher
6
+ module Storage
7
+ module Api
8
+ class TableAPI < BaseAPI
9
+ # Filter changes for specific table
10
+ #
11
+ # @param table_name [String] table name
12
+ # @return [TableAPI] self for method chaining
13
+ def changes_for(table_name)
14
+ @table_name = table_name
15
+ self
16
+ end
17
+
18
+ # Filter to recent changes
19
+ #
20
+ # @param days [Integer] number of days back
21
+ # @return [TableAPI] self for method chaining
22
+ def recent(days: 7)
23
+ filters[:recent_days] = days
24
+ self
25
+ end
26
+
27
+ # Filter by operation type
28
+ #
29
+ # @param operation [String, Symbol] operation type
30
+ # @return [TableAPI] self for method chaining
31
+ def by_operation(operation)
32
+ filters[:operation] = normalize_operation(operation)
33
+ self
34
+ end
35
+
36
+ # Get all filtered changes
37
+ #
38
+ # @return [Array<Hash>] filtered changes
39
+ def all
40
+ return [] unless @table_name
41
+
42
+ changes = storage.load_changes(@table_name)
43
+ apply_filters(changes)
44
+ end
45
+
46
+ # Get most active tables
47
+ #
48
+ # @param limit [Integer] maximum number of tables
49
+ # @return [Array<Hash>] most active tables
50
+ def most_active(limit: 10)
51
+ # This would require aggregating across all tables
52
+ # For now, return empty array - can be implemented later
53
+ # TODO: Implement most active tables functionality with limit parameter
54
+ _ = limit # Acknowledge the parameter for future use
55
+ []
56
+ end
57
+
58
+ private
59
+
60
+ def apply_filters(changes)
61
+ result = changes
62
+
63
+ # Apply recent filter using symbols only
64
+ if filters[:recent_days]
65
+ cutoff = Time.now - (filters[:recent_days] * 24 * 60 * 60)
66
+ result = result.select do |change|
67
+ timestamp = normalize_timestamp(safe_extract(change, :timestamp))
68
+ timestamp >= cutoff
69
+ end
70
+ end
71
+
72
+ # Apply operation filter using symbols only
73
+ if filters[:operation]
74
+ result = result.select do |change|
75
+ operation = normalize_operation(safe_extract(change, :operation))
76
+ operation == filters[:operation]
77
+ end
78
+ end
79
+
80
+ # Apply common filters
81
+ apply_common_filters(result)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_manager"
4
+ require_relative "errors"
5
+ require_relative "concerns/error_handler"
6
+ require_relative "concerns/timestampable"
7
+ require_relative "concerns/validatable"
8
+
9
+ module Dbwatcher
10
+ module Storage
11
+ # Base class for all storage implementations
12
+ #
13
+ # Provides common functionality for storage classes including
14
+ # file management, error handling, validation, and timestamping.
15
+ # Follows the Ruby style guide patterns for class organization.
16
+ #
17
+ # @abstract Subclass and implement specific storage logic
18
+ # @example
19
+ # class MyStorage < BaseStorage
20
+ # include Concerns::Validatable
21
+ #
22
+ # validates_presence_of :id, :name
23
+ #
24
+ # def save(data)
25
+ # validate_presence!(data)
26
+ # safe_write_json(file_path, data)
27
+ # end
28
+ # end
29
+ class BaseStorage
30
+ # Include shared concerns
31
+ include Concerns::ErrorHandler
32
+ include Concerns::Timestampable
33
+
34
+ # Configuration constants
35
+ DEFAULT_PERMISSIONS = 0o755
36
+ JSON_FILE_EXTENSION = ".json"
37
+
38
+ # @return [String] the configured storage path
39
+ attr_reader :storage_path
40
+
41
+ # @return [FileManager] the file manager instance
42
+ attr_reader :file_manager
43
+
44
+ # Initializes the base storage with configured path and file manager
45
+ #
46
+ # Sets up the storage path from configuration, creates a file manager
47
+ # instance, and initializes timestamps.
48
+ #
49
+ # @param storage_path [String, nil] custom storage path (optional)
50
+ def initialize(storage_path = nil)
51
+ @storage_path = storage_path || Dbwatcher.configuration.storage_path
52
+ @file_manager = FileManager.new(@storage_path)
53
+ initialize_timestamps
54
+ ensure_storage_directory
55
+ end
56
+
57
+ protected
58
+
59
+ # Safely writes JSON data to a file
60
+ #
61
+ # @param file_path [String] the path to write to
62
+ # @param data [Object] the data to serialize as JSON
63
+ # @return [Boolean] true if successful, false otherwise
64
+ def safe_write_json(file_path, data)
65
+ safe_operation("write JSON to #{file_path}") do
66
+ file_manager.write_json(file_path, data)
67
+ touch_updated_at
68
+ true
69
+ end
70
+ end
71
+
72
+ # Safely reads JSON data from a file
73
+ #
74
+ # @param file_path [String] the path to read from
75
+ # @param default [Object] default value if file doesn't exist or is invalid
76
+ # @return [Object] the parsed JSON data or default value
77
+ def safe_read_json(file_path, default = [])
78
+ safe_operation("read JSON from #{file_path}", default) do
79
+ file_manager.read_json(file_path)
80
+ end
81
+ end
82
+
83
+ # Removes a file safely
84
+ #
85
+ # @param file_path [String] the path to the file to remove
86
+ # @return [Boolean] true if file was removed, false if it didn't exist
87
+ def safe_delete_file(file_path)
88
+ safe_operation("delete file #{file_path}") do
89
+ file_manager.delete_file(file_path)
90
+ end
91
+ end
92
+
93
+ # Removes a directory safely
94
+ #
95
+ # @params directory_path [String] the path to the directory to remove
96
+ # @return [Boolean] true if directory was removed, false if it didn't exist
97
+ def safe_delete_directory(directory_path)
98
+ safe_operation("delete directory #{directory_path}") do
99
+ file_manager.delete_directory(directory_path)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ # Ensures the storage directory exists with proper permissions
106
+ #
107
+ # @return [void]
108
+ def ensure_storage_directory
109
+ file_manager.ensure_directory(storage_path)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ class ChangeProcessor
6
+ include Concerns::DataNormalizer
7
+
8
+ def initialize(session_storage)
9
+ @session_storage = session_storage
10
+ @session_cache = {}
11
+ end
12
+
13
+ def process_table_changes(table_name)
14
+ all_changes = collect_all_changes(table_name)
15
+ sort_changes_by_timestamp(all_changes)
16
+ end
17
+
18
+ private
19
+
20
+ def collect_all_changes(table_name)
21
+ changes = []
22
+
23
+ @session_storage.all.each do |session_info|
24
+ session_changes = extract_table_changes_from_session(session_info[:id], table_name)
25
+ changes.concat(session_changes)
26
+ end
27
+
28
+ changes
29
+ end
30
+
31
+ def extract_table_changes_from_session(session_id, table_name)
32
+ session = load_session_with_cache(session_id)
33
+ return [] unless session.respond_to?(:changes) && session.changes
34
+
35
+ session.changes
36
+ .select { |change| matches_table?(change, table_name) }
37
+ .map { |change| enrich_change(change, session) }
38
+ end
39
+
40
+ def load_session_with_cache(session_id)
41
+ @session_cache[session_id] ||= @session_storage.load(session_id)
42
+ end
43
+
44
+ def matches_table?(change, table_name)
45
+ extract_value(change, :table_name) == table_name
46
+ end
47
+
48
+ def enrich_change(change, session)
49
+ normalized_change = normalize_change(change)
50
+ add_session_context(normalized_change, session)
51
+ end
52
+
53
+ def add_session_context(change, session)
54
+ change.merge(
55
+ session_id: session.id,
56
+ session_name: session.name
57
+ )
58
+ end
59
+
60
+ def sort_changes_by_timestamp(changes)
61
+ changes.sort_by { |change| normalize_timestamp(extract_value(change, "timestamp")) }.reverse
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ module Concerns
6
+ # Provides consistent data normalization capabilities across storage classes
7
+ #
8
+ # This concern standardizes how different data types are normalized to ensure
9
+ # consistent symbol-key usage and proper data formatting throughout the storage layer.
10
+ #
11
+ # @example
12
+ # class MyStorage < BaseStorage
13
+ # include Concerns::DataNormalizer
14
+ #
15
+ # def save(data)
16
+ # normalized_data = normalize_session_data(data)
17
+ # # ... save logic
18
+ # end
19
+ # end
20
+ module DataNormalizer
21
+ # Normalizes session input to hash with consistent symbol keys
22
+ #
23
+ # @param session [Hash, Object] session data to normalize
24
+ # @return [Hash] normalized session hash with symbol keys
25
+ def normalize_session_data(session)
26
+ case session
27
+ when Hash
28
+ normalize_hash_keys(session)
29
+ when ->(s) { s.respond_to?(:to_h) }
30
+ normalize_hash_keys(session.to_h)
31
+ else
32
+ extract_object_attributes(session)
33
+ end
34
+ end
35
+
36
+ # Normalizes hash keys to symbols (Rails-compatible)
37
+ #
38
+ # @param hash [Hash] hash to normalize
39
+ # @return [Hash] hash with symbolized keys
40
+ def normalize_hash_keys(hash)
41
+ return hash unless hash.is_a?(Hash)
42
+
43
+ if hash.respond_to?(:with_indifferent_access)
44
+ hash.with_indifferent_access.symbolize_keys
45
+ else
46
+ hash.transform_keys { |key| key.to_s.to_sym }
47
+ end
48
+ end
49
+
50
+ # Normalize change data to use consistent symbol keys
51
+ #
52
+ # @param change [Hash] change data to normalize
53
+ # @return [Hash] normalized change hash with symbol keys
54
+ def normalize_change(change)
55
+ return change unless change.is_a?(Hash)
56
+
57
+ normalize_hash_keys(change)
58
+ end
59
+
60
+ # Extract value by trying both string and symbol keys
61
+ #
62
+ # @param hash [Hash] hash to extract from
63
+ # @param key [String, Symbol] key to extract
64
+ # @return [Object] extracted value or nil
65
+ def extract_value(hash, key)
66
+ return nil unless hash.is_a?(Hash)
67
+
68
+ hash[key.to_sym] || hash[key.to_s]
69
+ end
70
+
71
+ # Normalize timestamp to consistent format
72
+ #
73
+ # @param timestamp [String, Time, Numeric] timestamp to normalize
74
+ # @return [Time] normalized timestamp
75
+ def normalize_timestamp(timestamp)
76
+ return Time.at(0) unless timestamp
77
+
78
+ case timestamp
79
+ when String
80
+ Time.parse(timestamp)
81
+ when Time
82
+ timestamp
83
+ when Numeric
84
+ Time.at(timestamp)
85
+ else
86
+ Time.at(0)
87
+ end
88
+ rescue ArgumentError, TypeError
89
+ Time.at(0)
90
+ end
91
+
92
+ # Normalize operation to uppercase string
93
+ #
94
+ # @param operation [String, Symbol] operation to normalize
95
+ # @return [String] uppercase operation string
96
+ def normalize_operation(operation)
97
+ operation&.to_s&.upcase
98
+ end
99
+
100
+ # Normalize table name to string
101
+ #
102
+ # @param table_name [String, Symbol] table name to normalize
103
+ # @return [String] normalized table name
104
+ def normalize_table_name(table_name)
105
+ table_name&.to_s
106
+ end
107
+
108
+ # Normalize record ID to string
109
+ #
110
+ # @param record_id [String, Integer] record ID to normalize
111
+ # @return [String] normalized record ID
112
+ def normalize_record_id(record_id)
113
+ record_id&.to_s
114
+ end
115
+
116
+ private
117
+
118
+ # Extracts attributes from objects that don't respond to to_h
119
+ #
120
+ # @param object [Object] object to extract attributes from
121
+ # @return [Hash] extracted attributes hash
122
+ def extract_object_attributes(object)
123
+ {
124
+ id: object.respond_to?(:id) ? object.id : nil,
125
+ name: object.respond_to?(:name) ? object.name : nil,
126
+ started_at: object.respond_to?(:started_at) ? object.started_at : nil,
127
+ ended_at: object.respond_to?(:ended_at) ? object.ended_at : nil,
128
+ changes: object.respond_to?(:changes) ? object.changes : []
129
+ }
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ module Concerns
6
+ # Provides standardized error handling capabilities for storage classes
7
+ #
8
+ # This concern can be included in storage classes to provide consistent
9
+ # error handling patterns, logging, and recovery mechanisms.
10
+ #
11
+ # @example
12
+ # class MyStorage < BaseStorage
13
+ # include Concerns::ErrorHandler
14
+ #
15
+ # def risky_operation
16
+ # safe_operation("my operation") do
17
+ # # potentially failing code
18
+ # end
19
+ # end
20
+ # end
21
+ module ErrorHandler
22
+ # Executes a block with error handling and optional default return value
23
+ #
24
+ # @param operation_name [String] description of the operation for logging
25
+ # @param default_value [Object] value to return if operation fails
26
+ # @yield [] the block to execute safely
27
+ # @return [Object] the result of the block or default_value on error
28
+ def safe_operation(operation_name, default_value = nil, &block)
29
+ block.call
30
+ rescue JSON::ParserError => e
31
+ log_error("JSON parsing failed in #{operation_name}", e)
32
+ default_value
33
+ rescue Errno::ENOENT => e
34
+ log_error("File not found in #{operation_name}", e)
35
+ default_value
36
+ rescue Errno::EACCES => e
37
+ log_error("Permission denied in #{operation_name}", e)
38
+ raise StorageError, "Permission denied: #{e.message}"
39
+ rescue StandardError => e
40
+ log_error("#{operation_name} failed", e)
41
+ default_value
42
+ end
43
+
44
+ # Executes a block with error handling that raises StorageError on failure
45
+ #
46
+ # @param operation [String] description of the operation
47
+ # @yield [] the block to execute
48
+ # @return [Object] the result of the block
49
+ # @raise [StorageError] if the operation fails
50
+ def with_error_handling(operation, &block)
51
+ block.call
52
+ rescue StandardError => e
53
+ error_message = "Storage #{operation} failed: #{e.message}"
54
+ log_error(error_message, e)
55
+ raise StorageError, error_message
56
+ end
57
+
58
+ private
59
+
60
+ # Logs an error message with exception details
61
+ #
62
+ # @param message [String] the error message
63
+ # @param error [Exception] the exception that occurred
64
+ # @return [void]
65
+ def log_error(message, error)
66
+ if defined?(Rails) && Rails.logger
67
+ Rails.logger.warn("#{message}: #{error.message}")
68
+ else
69
+ warn "#{message}: #{error.message}"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end