dbwatcher 0.1.5 → 1.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +101 -0
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +20 -0
  33. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  34. data/app/controllers/dbwatcher/sessions_controller.rb +30 -20
  35. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  36. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  37. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  38. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  39. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  40. data/app/helpers/dbwatcher/session_helper.rb +28 -0
  41. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  42. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  43. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  44. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  45. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  46. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  47. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  48. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  49. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  50. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  51. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  52. data/app/views/dbwatcher/sessions/index.html.erb +124 -27
  53. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  54. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  55. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  56. data/app/views/dbwatcher/sessions/show.html.erb +3 -149
  57. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  58. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  59. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  60. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  61. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  62. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  63. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  64. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  65. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  66. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  67. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  68. data/app/views/layouts/dbwatcher/application.html.erb +252 -25
  69. data/bin/compile_scss +49 -0
  70. data/config/routes.rb +43 -3
  71. data/lib/dbwatcher/configuration.rb +103 -1
  72. data/lib/dbwatcher/engine.rb +28 -13
  73. data/lib/dbwatcher/logging.rb +72 -0
  74. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  75. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  76. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  77. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  78. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  79. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  80. data/lib/dbwatcher/services/base_service.rb +64 -0
  81. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  82. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  83. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  84. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  85. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  86. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  87. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  88. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  89. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  90. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  91. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  92. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  93. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  94. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  95. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  96. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  97. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  98. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  99. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  100. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  101. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  102. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  103. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  104. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  105. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  106. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  107. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  108. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  109. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  110. data/lib/dbwatcher/sql_logger.rb +107 -0
  111. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  112. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +59 -0
  113. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  114. data/lib/dbwatcher/storage/api/session_api.rb +181 -0
  115. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  116. data/lib/dbwatcher/storage/base_storage.rb +120 -0
  117. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  118. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  119. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  120. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  121. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  122. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  123. data/lib/dbwatcher/storage/errors.rb +86 -0
  124. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  125. data/lib/dbwatcher/storage/null_session.rb +39 -0
  126. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  127. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  128. data/lib/dbwatcher/storage/session.rb +58 -0
  129. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  130. data/lib/dbwatcher/storage/session_query.rb +71 -0
  131. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  132. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  133. data/lib/dbwatcher/storage.rb +112 -85
  134. data/lib/dbwatcher/tracker.rb +4 -55
  135. data/lib/dbwatcher/version.rb +1 -1
  136. data/lib/dbwatcher.rb +70 -3
  137. metadata +140 -2
@@ -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,181 @@
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
+ # Get comprehensive session analysis including tables and relationships
95
+ #
96
+ # @param session_id [String] session identifier
97
+ # @return [Hash] session analysis data
98
+ def summary(session_id)
99
+ session = find(session_id)
100
+ return { error: "Session not found" } unless session
101
+
102
+ {
103
+ tables_summary: tables_summary(session_id),
104
+ total_changes: session.changes&.count || 0,
105
+ session_metadata: extract_session_metadata(session)
106
+ }
107
+ end
108
+
109
+ # Get tables summary for a session
110
+ #
111
+ # @param session_id [String] session identifier
112
+ # @return [Hash] tables summary
113
+ def tables_summary(session_id)
114
+ session = find(session_id)
115
+ return { error: "Session not found" } unless session
116
+
117
+ analyzer = Dbwatcher::Services::Analyzers::TableSummaryBuilder.new(session)
118
+ analyzer.call
119
+ end
120
+
121
+ # Generate diagram data for a session
122
+ #
123
+ # @param session_id [String] session identifier
124
+ # @param diagram_type [String] type of diagram to generate
125
+ # @return [Hash] diagram data
126
+ def diagram_data(session_id, diagram_type = "database_tables")
127
+ Dbwatcher::Services::DiagramSystem.generate(session_id, diagram_type)
128
+ end
129
+
130
+ private
131
+
132
+ def apply_filters(sessions)
133
+ sessions
134
+ .then { |s| filter_by_start_time(s) }
135
+ .then { |s| filter_by_status(s) }
136
+ .then { |s| filter_by_name_pattern(s) }
137
+ .then { |s| filter_by_changes(s) }
138
+ .then { |s| apply_common_filters(s) }
139
+ end
140
+
141
+ def filter_by_start_time(sessions)
142
+ return sessions unless filters[:started_after]
143
+
144
+ apply_time_filter(sessions, :started_at)
145
+ end
146
+
147
+ def filter_by_status(sessions)
148
+ return sessions unless filters[:status]
149
+
150
+ sessions.select { |s| safe_extract(s, :status) == filters[:status] }
151
+ end
152
+
153
+ def filter_by_name_pattern(sessions)
154
+ return sessions unless filters[:name_pattern]
155
+
156
+ apply_pattern_filter(sessions, %i[name id], filters[:name_pattern])
157
+ end
158
+
159
+ def filter_by_changes(sessions)
160
+ return sessions unless filters[:has_changes]
161
+
162
+ sessions.select do |s|
163
+ session = find(safe_extract(s, :id))
164
+ session&.changes&.any?
165
+ end
166
+ end
167
+
168
+ # Extract session metadata for analysis
169
+ #
170
+ # @param session [Session] session object
171
+ # @return [Hash] metadata hash
172
+ def extract_session_metadata(session)
173
+ {
174
+ id: session.id,
175
+ changes_count: session.changes&.count || 0
176
+ }
177
+ end
178
+ end
179
+ end
180
+ end
181
+ 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,120 @@
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
+ # @raise [StorageError] if storage_path is nil or empty
51
+ def initialize(storage_path = nil)
52
+ @storage_path = storage_path || Dbwatcher.configuration.storage_path
53
+
54
+ # Ensure storage path is valid
55
+ if @storage_path.nil? || @storage_path.to_s.strip.empty?
56
+ raise StorageError, "Storage path cannot be nil or empty. Please configure a valid storage path."
57
+ end
58
+
59
+ @file_manager = FileManager.new(@storage_path)
60
+ initialize_timestamps
61
+ ensure_storage_directory
62
+ end
63
+
64
+ protected
65
+
66
+ # Safely writes JSON data to a file
67
+ #
68
+ # @param file_path [String] the path to write to
69
+ # @param data [Object] the data to serialize as JSON
70
+ # @return [Boolean] true if successful, false otherwise
71
+ def safe_write_json(file_path, data)
72
+ safe_operation("write JSON to #{file_path}") do
73
+ file_manager.write_json(file_path, data)
74
+ touch_updated_at
75
+ true
76
+ end
77
+ end
78
+
79
+ # Safely reads JSON data from a file
80
+ #
81
+ # @param file_path [String] the path to read from
82
+ # @param default [Object] default value if file doesn't exist or is invalid
83
+ # @return [Object] the parsed JSON data or default value
84
+ def safe_read_json(file_path, default = [])
85
+ safe_operation("read JSON from #{file_path}", default) do
86
+ file_manager.read_json(file_path)
87
+ end
88
+ end
89
+
90
+ # Removes a file safely
91
+ #
92
+ # @param file_path [String] the path to the file to remove
93
+ # @return [Boolean] true if file was removed, false if it didn't exist
94
+ def safe_delete_file(file_path)
95
+ safe_operation("delete file #{file_path}") do
96
+ file_manager.delete_file(file_path)
97
+ end
98
+ end
99
+
100
+ # Removes a directory safely
101
+ #
102
+ # @params directory_path [String] the path to the directory to remove
103
+ # @return [Boolean] true if directory was removed, false if it didn't exist
104
+ def safe_delete_directory(directory_path)
105
+ safe_operation("delete directory #{directory_path}") do
106
+ file_manager.delete_directory(directory_path)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # Ensures the storage directory exists with proper permissions
113
+ #
114
+ # @return [void]
115
+ def ensure_storage_directory
116
+ file_manager.ensure_directory(storage_path)
117
+ end
118
+ end
119
+ end
120
+ 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