orfeas_lyra 0.6.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 (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +222 -0
  3. data/LICENSE +21 -0
  4. data/README.md +1165 -0
  5. data/Rakefile +728 -0
  6. data/app/controllers/lyra/application_controller.rb +23 -0
  7. data/app/controllers/lyra/dashboard_controller.rb +624 -0
  8. data/app/controllers/lyra/flow_controller.rb +224 -0
  9. data/app/controllers/lyra/privacy_controller.rb +182 -0
  10. data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
  11. data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
  12. data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
  13. data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
  14. data/app/views/lyra/dashboard/index.html.erb +119 -0
  15. data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
  16. data/app/views/lyra/dashboard/projections.html.erb +302 -0
  17. data/app/views/lyra/dashboard/schema.html.erb +283 -0
  18. data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
  19. data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
  20. data/app/views/lyra/dashboard/verification.html.erb +370 -0
  21. data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
  22. data/app/views/lyra/flow/timeline.html.erb +260 -0
  23. data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
  24. data/app/views/lyra/privacy/policy.html.erb +188 -0
  25. data/app/workflows/es_async_mode_workflow.rb +80 -0
  26. data/app/workflows/es_sync_mode_workflow.rb +64 -0
  27. data/app/workflows/hijack_mode_workflow.rb +54 -0
  28. data/app/workflows/lifecycle_workflow.rb +43 -0
  29. data/app/workflows/monitor_mode_workflow.rb +39 -0
  30. data/config/privacy_policies.rb +273 -0
  31. data/config/routes.rb +48 -0
  32. data/lib/lyra/aggregate.rb +131 -0
  33. data/lib/lyra/associations/event_aware.rb +225 -0
  34. data/lib/lyra/command.rb +81 -0
  35. data/lib/lyra/command_handler.rb +155 -0
  36. data/lib/lyra/configuration.rb +124 -0
  37. data/lib/lyra/consistency/read_your_writes.rb +91 -0
  38. data/lib/lyra/correlation.rb +144 -0
  39. data/lib/lyra/dual_view.rb +231 -0
  40. data/lib/lyra/engine.rb +67 -0
  41. data/lib/lyra/event.rb +71 -0
  42. data/lib/lyra/event_analyzer.rb +135 -0
  43. data/lib/lyra/event_flow.rb +449 -0
  44. data/lib/lyra/event_mapper.rb +106 -0
  45. data/lib/lyra/event_store_adapter.rb +72 -0
  46. data/lib/lyra/id_generator.rb +137 -0
  47. data/lib/lyra/interceptors/association_interceptor.rb +169 -0
  48. data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
  49. data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
  50. data/lib/lyra/privacy/pii_detector.rb +85 -0
  51. data/lib/lyra/privacy/pii_masker.rb +66 -0
  52. data/lib/lyra/privacy/policy_integration.rb +253 -0
  53. data/lib/lyra/projection.rb +94 -0
  54. data/lib/lyra/projections/async_projection_job.rb +63 -0
  55. data/lib/lyra/projections/cached_projection.rb +322 -0
  56. data/lib/lyra/projections/cached_relation.rb +757 -0
  57. data/lib/lyra/projections/event_store_reader.rb +127 -0
  58. data/lib/lyra/projections/model_projection.rb +143 -0
  59. data/lib/lyra/schema/diff.rb +331 -0
  60. data/lib/lyra/schema/event_class_registrar.rb +63 -0
  61. data/lib/lyra/schema/generator.rb +190 -0
  62. data/lib/lyra/schema/reporter.rb +188 -0
  63. data/lib/lyra/schema/store.rb +156 -0
  64. data/lib/lyra/schema/validator.rb +100 -0
  65. data/lib/lyra/strict_data_access.rb +363 -0
  66. data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
  67. data/lib/lyra/verification/workflow_generator.rb +540 -0
  68. data/lib/lyra/version.rb +3 -0
  69. data/lib/lyra/visualization/activity_heatmap.rb +215 -0
  70. data/lib/lyra/visualization/event_graph.rb +310 -0
  71. data/lib/lyra/visualization/timeline.rb +398 -0
  72. data/lib/lyra.rb +150 -0
  73. data/lib/tasks/dist.rake +391 -0
  74. data/lib/tasks/gems.rake +185 -0
  75. data/lib/tasks/lyra_schema.rake +231 -0
  76. data/lib/tasks/lyra_workflows.rake +452 -0
  77. data/lib/tasks/public_release.rake +351 -0
  78. data/lib/tasks/stats.rake +175 -0
  79. data/lib/tasks/testbed.rake +479 -0
  80. data/lib/tasks/version.rake +159 -0
  81. metadata +221 -0
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Projections
5
+ # Reads model state from event store when projections are disabled.
6
+ #
7
+ # Uses CachedProjection (backed by Solid Cache or Rails.cache) for
8
+ # fast reads while keeping events as the source of truth.
9
+ #
10
+ # Usage:
11
+ # EventStoreReader.find(User, 123)
12
+ # EventStoreReader.find_by(User, email: "test@example.com")
13
+ # EventStoreReader.where(User, status: "active")
14
+ #
15
+ class EventStoreReader
16
+ class << self
17
+ # Find a record by ID (cached)
18
+ #
19
+ # @param model_class [Class] The ActiveRecord model class
20
+ # @param id [Integer, String] The record ID
21
+ # @return [ActiveRecord::Base, nil] The reconstructed record or nil
22
+ def find(model_class, id)
23
+ attributes = CachedProjection.find(model_class, id)
24
+ return nil unless attributes
25
+
26
+ build_instance(model_class, attributes)
27
+ end
28
+
29
+ # Find a record by attributes (cached)
30
+ #
31
+ # @param model_class [Class] The ActiveRecord model class
32
+ # @param attributes [Hash] The attributes to match
33
+ # @return [ActiveRecord::Base, nil] The first matching record or nil
34
+ def find_by(model_class, attributes)
35
+ result = CachedProjection.find_by(model_class, attributes)
36
+ return nil unless result
37
+
38
+ build_instance(model_class, result)
39
+ end
40
+
41
+ # Check if a record exists (cached)
42
+ #
43
+ # @param model_class [Class] The ActiveRecord model class
44
+ # @param id [Integer, String] The record ID
45
+ # @return [Boolean] True if record exists and is not destroyed
46
+ def exists?(model_class, id)
47
+ CachedProjection.exists?(model_class, id)
48
+ end
49
+
50
+ # Get a CachedRelation for the model (supports method chaining)
51
+ #
52
+ # @param model_class [Class] The ActiveRecord model class
53
+ # @return [CachedRelation] A relation-like object for chaining
54
+ def relation(model_class)
55
+ results = CachedProjection.all(model_class)
56
+ records = results.map { |attrs| build_instance(model_class, attrs) }.compact
57
+ CachedRelation.new(model_class, records)
58
+ end
59
+
60
+ # Load all records of a given type (cached)
61
+ # Warning: Can be expensive for large datasets
62
+ #
63
+ # @param model_class [Class] The ActiveRecord model class
64
+ # @return [CachedRelation] A relation containing all reconstructed records
65
+ def all(model_class)
66
+ relation(model_class)
67
+ end
68
+
69
+ # Query records with conditions (cached)
70
+ #
71
+ # @param model_class [Class] The ActiveRecord model class
72
+ # @param conditions [Hash] Query conditions
73
+ # @return [CachedRelation] A relation containing matching records
74
+ def where(model_class, conditions)
75
+ relation(model_class).where(conditions)
76
+ end
77
+
78
+ # Count records (cached)
79
+ #
80
+ # @param model_class [Class] The ActiveRecord model class
81
+ # @param conditions [Hash] Optional conditions
82
+ # @return [Integer] Count of matching records
83
+ def count(model_class, conditions = {})
84
+ CachedProjection.count(model_class, conditions)
85
+ end
86
+
87
+ # Invalidate cache for a record (call after event stored)
88
+ #
89
+ # @param model_class [Class] The ActiveRecord model class
90
+ # @param id [Integer, String] The record ID
91
+ def invalidate(model_class, id)
92
+ CachedProjection.invalidate(model_class, id)
93
+ end
94
+
95
+ # Warm cache for a record (call after event stored)
96
+ #
97
+ # @param model_class [Class] The ActiveRecord model class
98
+ # @param id [Integer, String] The record ID
99
+ def warm(model_class, id)
100
+ CachedProjection.warm(model_class, id)
101
+ end
102
+
103
+ private
104
+
105
+ def build_instance(model_class, attributes)
106
+ # Ensure attributes are stringified
107
+ attrs = attributes.transform_keys(&:to_s)
108
+
109
+ # Filter to only known columns
110
+ column_names = model_class.column_names
111
+ filtered_attrs = attrs.slice(*column_names)
112
+
113
+ # Use AR's instantiate method - proper way to build from DB-style attributes
114
+ record = model_class.instantiate(filtered_attrs)
115
+
116
+ # Note: We don't mark as readonly because the app may need to modify
117
+ # and re-save (which will go through event sourcing)
118
+
119
+ record
120
+ rescue => e
121
+ Rails.logger.warn("Lyra::EventStoreReader: Failed to build instance - #{e.message}")
122
+ nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Projections
5
+ # Synchronous projection handler for updating model tables from events.
6
+ #
7
+ # In event_sourcing mode, this class is responsible for keeping the model
8
+ # (read model) tables in sync with the event store. It uses raw SQL operations
9
+ # (insert, update_all, delete_all) to avoid triggering ActiveRecord callbacks
10
+ # or PaperTrail versioning.
11
+ #
12
+ # Usage:
13
+ # ModelProjection.project(Registration, :create, result)
14
+ # ModelProjection.project(Registration, :update, result)
15
+ # ModelProjection.project(Registration, :destroy, result)
16
+ #
17
+ class ModelProjection
18
+ class << self
19
+ # Project a command result to the model table
20
+ #
21
+ # @param model_class [Class] The ActiveRecord model class
22
+ # @param operation [Symbol] :create, :update, or :destroy
23
+ # @param result [CommandResult] The result from CommandHandler
24
+ def project(model_class, operation, result)
25
+ case operation
26
+ when :create
27
+ project_create(model_class, result)
28
+ when :update
29
+ project_update(model_class, result)
30
+ when :destroy
31
+ project_destroy(model_class, result)
32
+ else
33
+ raise ArgumentError, "Unknown operation: #{operation}"
34
+ end
35
+ end
36
+
37
+ # Insert a new record into the model table
38
+ #
39
+ # Uses insert() to bypass all callbacks and validations.
40
+ # This is intentional - the event is the source of truth.
41
+ def project_create(model_class, result)
42
+ attributes = result.attributes.dup
43
+
44
+ # Ensure we have an ID
45
+ raise "Cannot project create without ID" unless attributes[:id] || attributes["id"]
46
+
47
+ # Add timestamps if not present
48
+ now = Time.current
49
+ attributes[:created_at] ||= now
50
+ attributes[:updated_at] ||= now
51
+
52
+ # Convert to database-safe format
53
+ insert_attrs = sanitize_attributes(model_class, attributes)
54
+
55
+ # Use insert to bypass callbacks
56
+ # Bypass strict data access - projections are legitimate bulk operations
57
+ previous = Thread.current[:lyra_bypass_strict_access]
58
+ Thread.current[:lyra_bypass_strict_access] = true
59
+ begin
60
+ model_class.insert(insert_attrs)
61
+ ensure
62
+ Thread.current[:lyra_bypass_strict_access] = previous
63
+ end
64
+ end
65
+
66
+ # Update an existing record in the model table
67
+ #
68
+ # Uses update_all() to bypass all callbacks.
69
+ def project_update(model_class, result)
70
+ event = result.events&.first
71
+ return unless event
72
+
73
+ model_id = event.data[:model_id] || event.data["model_id"]
74
+ changes = event.data[:changes] || event.data["changes"] || {}
75
+
76
+ return if changes.empty?
77
+
78
+ # Extract new values from changes (changes are [old_value, new_value] tuples)
79
+ # Use symbol keys consistently to avoid duplicate column errors
80
+ updates = {}
81
+ changes.each do |field, change|
82
+ new_value = change.is_a?(Array) ? change.last : change
83
+ updates[field.to_sym] = new_value
84
+ end
85
+
86
+ # Ensure updated_at timestamp (may already be in changes)
87
+ updates[:updated_at] ||= Time.current
88
+
89
+ # Use update_all to bypass callbacks
90
+ # Bypass strict data access - projections are legitimate bulk operations
91
+ previous = Thread.current[:lyra_bypass_strict_access]
92
+ Thread.current[:lyra_bypass_strict_access] = true
93
+ begin
94
+ model_class.where(id: model_id).update_all(updates)
95
+ ensure
96
+ Thread.current[:lyra_bypass_strict_access] = previous
97
+ end
98
+ end
99
+
100
+ # Delete a record from the model table
101
+ #
102
+ # Uses delete_all() to bypass callbacks and dependent destroy.
103
+ def project_destroy(model_class, result)
104
+ event = result.events&.first
105
+ return unless event
106
+
107
+ model_id = event.data[:model_id] || event.data["model_id"]
108
+
109
+ # Use delete_all to bypass callbacks
110
+ # Bypass strict data access - projections are legitimate bulk operations
111
+ previous = Thread.current[:lyra_bypass_strict_access]
112
+ Thread.current[:lyra_bypass_strict_access] = true
113
+ begin
114
+ model_class.where(id: model_id).delete_all
115
+ ensure
116
+ Thread.current[:lyra_bypass_strict_access] = previous
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # Sanitize attributes for database insertion
123
+ #
124
+ # Removes attributes that don't correspond to database columns
125
+ # and ensures proper type conversion.
126
+ def sanitize_attributes(model_class, attributes)
127
+ column_names = model_class.column_names.map(&:to_s)
128
+
129
+ attributes.stringify_keys.slice(*column_names).tap do |attrs|
130
+ # Convert any special types
131
+ attrs.each do |key, value|
132
+ # Handle BigDecimal/Float precision
133
+ column = model_class.columns_hash[key]
134
+ if column&.type == :decimal && value.is_a?(Float)
135
+ attrs[key] = BigDecimal(value.to_s)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Schema
5
+ # Compares schemas and produces human-readable difference reports
6
+ class Diff
7
+ # Severity levels for different types of changes
8
+ SEVERITIES = {
9
+ model_added: :info,
10
+ model_removed: :breaking,
11
+ column_added: :info,
12
+ column_removed: :breaking,
13
+ column_type_changed: :breaking,
14
+ column_nullable_changed: :warning,
15
+ column_limit_changed: :warning,
16
+ pii_field_added: :warning,
17
+ pii_field_removed: :info,
18
+ event_name_changed: :breaking,
19
+ event_prefix_changed: :warning,
20
+ config_changed: :info
21
+ }.freeze
22
+
23
+ class << self
24
+ def compare(old_schema, new_schema)
25
+ new.compare(old_schema, new_schema)
26
+ end
27
+
28
+ def format_report(differences)
29
+ new.format_report(differences)
30
+ end
31
+ end
32
+
33
+ def compare(old_schema, new_schema)
34
+ differences = []
35
+
36
+ # Compare models (normalize keys to strings for comparison)
37
+ old_models = normalize_keys(old_schema[:models] || {})
38
+ new_models = normalize_keys(new_schema[:models] || {})
39
+
40
+ # Check for removed models
41
+ (old_models.keys - new_models.keys).each do |model_name|
42
+ differences << {
43
+ type: :model_removed,
44
+ severity: SEVERITIES[:model_removed],
45
+ model: model_name,
46
+ message: "Model '#{model_name}' was removed from monitoring"
47
+ }
48
+ end
49
+
50
+ # Check for added models
51
+ (new_models.keys - old_models.keys).each do |model_name|
52
+ differences << {
53
+ type: :model_added,
54
+ severity: SEVERITIES[:model_added],
55
+ model: model_name,
56
+ message: "Model '#{model_name}' was added to monitoring"
57
+ }
58
+ end
59
+
60
+ # Compare existing models
61
+ (old_models.keys & new_models.keys).each do |model_name|
62
+ differences += compare_model(
63
+ model_name,
64
+ old_models[model_name],
65
+ new_models[model_name]
66
+ )
67
+ end
68
+
69
+ # Compare configuration changes
70
+ differences += compare_configuration(
71
+ old_schema[:configuration] || {},
72
+ new_schema[:configuration] || {}
73
+ )
74
+
75
+ differences
76
+ end
77
+
78
+ def compare_model(model_name, old_model, new_model)
79
+ differences = []
80
+
81
+ # Access with string keys (models are already normalized)
82
+ old_prefix = old_model["event_prefix"]
83
+ new_prefix = new_model["event_prefix"]
84
+
85
+ # Compare event prefix
86
+ if old_prefix != new_prefix
87
+ differences << {
88
+ type: :event_prefix_changed,
89
+ severity: SEVERITIES[:event_prefix_changed],
90
+ model: model_name,
91
+ old_value: old_prefix,
92
+ new_value: new_prefix,
93
+ message: "#{model_name} event_prefix changed: '#{old_prefix}' -> '#{new_prefix}'"
94
+ }
95
+ end
96
+
97
+ # Compare columns
98
+ differences += compare_columns(model_name, old_model["columns"] || {}, new_model["columns"] || {})
99
+
100
+ # Compare events
101
+ differences += compare_events(model_name, old_model["events"] || {}, new_model["events"] || {})
102
+
103
+ differences
104
+ end
105
+
106
+ def compare_columns(model_name, old_columns, new_columns)
107
+ differences = []
108
+
109
+ # Removed columns
110
+ (old_columns.keys - new_columns.keys).each do |col_name|
111
+ differences << {
112
+ type: :column_removed,
113
+ severity: SEVERITIES[:column_removed],
114
+ model: model_name,
115
+ column: col_name,
116
+ message: "Column '#{col_name}' removed from #{model_name}"
117
+ }
118
+ end
119
+
120
+ # Added columns
121
+ (new_columns.keys - old_columns.keys).each do |col_name|
122
+ col_info = new_columns[col_name]
123
+ msg = "Column '#{col_name}' added to #{model_name}"
124
+ msg += " (PII: #{col_info["pii_type"]})" if col_info["pii"]
125
+
126
+ differences << {
127
+ type: :column_added,
128
+ severity: SEVERITIES[:column_added],
129
+ model: model_name,
130
+ column: col_name,
131
+ pii: col_info["pii"],
132
+ message: msg
133
+ }
134
+ end
135
+
136
+ # Changed columns
137
+ (old_columns.keys & new_columns.keys).each do |col_name|
138
+ differences += compare_column(model_name, col_name, old_columns[col_name], new_columns[col_name])
139
+ end
140
+
141
+ differences
142
+ end
143
+
144
+ def compare_column(model_name, col_name, old_col, new_col)
145
+ differences = []
146
+
147
+ # Use string keys (columns are already normalized)
148
+ old_type = old_col["type"]
149
+ new_type = new_col["type"]
150
+ old_nullable = old_col["nullable"]
151
+ new_nullable = new_col["nullable"]
152
+ old_limit = old_col["limit"]
153
+ new_limit = new_col["limit"]
154
+ old_pii = old_col["pii"]
155
+ new_pii = new_col["pii"]
156
+
157
+ # Type change
158
+ if old_type != new_type
159
+ differences << {
160
+ type: :column_type_changed,
161
+ severity: SEVERITIES[:column_type_changed],
162
+ model: model_name,
163
+ column: col_name,
164
+ old_value: old_type,
165
+ new_value: new_type,
166
+ message: "#{model_name}.#{col_name} type changed: #{old_type} -> #{new_type}"
167
+ }
168
+ end
169
+
170
+ # Nullable change
171
+ if old_nullable != new_nullable
172
+ differences << {
173
+ type: :column_nullable_changed,
174
+ severity: SEVERITIES[:column_nullable_changed],
175
+ model: model_name,
176
+ column: col_name,
177
+ old_value: old_nullable,
178
+ new_value: new_nullable,
179
+ message: "#{model_name}.#{col_name} nullable changed: #{old_nullable} -> #{new_nullable}"
180
+ }
181
+ end
182
+
183
+ # Limit change
184
+ if old_limit != new_limit
185
+ differences << {
186
+ type: :column_limit_changed,
187
+ severity: SEVERITIES[:column_limit_changed],
188
+ model: model_name,
189
+ column: col_name,
190
+ old_value: old_limit,
191
+ new_value: new_limit,
192
+ message: "#{model_name}.#{col_name} limit changed: #{old_limit} -> #{new_limit}"
193
+ }
194
+ end
195
+
196
+ # PII detection change
197
+ if old_pii != new_pii
198
+ type = new_pii ? :pii_field_added : :pii_field_removed
199
+ differences << {
200
+ type: type,
201
+ severity: SEVERITIES[type],
202
+ model: model_name,
203
+ column: col_name,
204
+ message: "#{model_name}.#{col_name} PII status changed: #{old_pii} -> #{new_pii}"
205
+ }
206
+ end
207
+
208
+ differences
209
+ end
210
+
211
+ def compare_events(model_name, old_events, new_events)
212
+ differences = []
213
+
214
+ # Check for event name changes (removed + added = renamed)
215
+ removed_events = old_events.keys - new_events.keys
216
+ added_events = new_events.keys - old_events.keys
217
+
218
+ # Detect renames by matching operations
219
+ removed_events.each do |old_name|
220
+ old_op = old_events[old_name]["operation"]
221
+
222
+ matching_new = added_events.find do |new_name|
223
+ new_events[new_name]["operation"] == old_op
224
+ end
225
+
226
+ if matching_new
227
+ differences << {
228
+ type: :event_name_changed,
229
+ severity: SEVERITIES[:event_name_changed],
230
+ model: model_name,
231
+ old_value: old_name,
232
+ new_value: matching_new,
233
+ operation: old_op,
234
+ message: "#{model_name} event renamed: #{old_name} -> #{matching_new}"
235
+ }
236
+ added_events.delete(matching_new)
237
+ end
238
+ end
239
+
240
+ differences
241
+ end
242
+
243
+ def compare_configuration(old_config, new_config)
244
+ differences = []
245
+
246
+ # Normalize to string keys for comparison
247
+ old_cfg = normalize_keys(old_config)
248
+ new_cfg = normalize_keys(new_config)
249
+
250
+ %w[mode strict_schema projection_mode].each do |key|
251
+ old_val = old_cfg[key]
252
+ new_val = new_cfg[key]
253
+
254
+ # Normalize values to strings for comparison
255
+ old_val_str = old_val.to_s if old_val
256
+ new_val_str = new_val.to_s if new_val
257
+
258
+ if old_val_str != new_val_str
259
+ differences << {
260
+ type: :config_changed,
261
+ severity: SEVERITIES[:config_changed],
262
+ key: key,
263
+ old_value: old_val,
264
+ new_value: new_val,
265
+ message: "Configuration '#{key}' changed: #{old_val} -> #{new_val}"
266
+ }
267
+ end
268
+ end
269
+
270
+ differences
271
+ end
272
+
273
+ def format_report(differences)
274
+ return "No schema changes detected." if differences.empty?
275
+
276
+ lines = ["Schema Changes Detected:"]
277
+ lines << "=" * 60
278
+
279
+ grouped = differences.group_by { |d| d[:severity] }
280
+
281
+ [:breaking, :warning, :info].each do |severity|
282
+ next unless grouped[severity]&.any?
283
+
284
+ lines << ""
285
+ icon = severity_icon(severity)
286
+ lines << "#{icon} #{severity.to_s.upcase} (#{grouped[severity].size}):"
287
+ lines << "-" * 40
288
+
289
+ grouped[severity].each do |diff|
290
+ lines << " - #{diff[:message]}"
291
+ end
292
+ end
293
+
294
+ lines << ""
295
+ lines << "=" * 60
296
+
297
+ # Add action suggestions
298
+ if grouped[:breaking]&.any?
299
+ lines << ""
300
+ lines << "ACTION REQUIRED: Breaking changes detected."
301
+ lines << "Run 'rake lyra:schema:update' to create a new schema version."
302
+ end
303
+
304
+ lines.join("\n")
305
+ end
306
+
307
+ private
308
+
309
+ def severity_icon(severity)
310
+ case severity
311
+ when :breaking then "[!]"
312
+ when :warning then "[?]"
313
+ when :info then "[i]"
314
+ else "[ ]"
315
+ end
316
+ end
317
+
318
+ # Normalize hash keys to strings recursively for consistent comparison
319
+ def normalize_keys(hash)
320
+ return {} unless hash.is_a?(Hash)
321
+
322
+ hash.transform_keys(&:to_s).transform_values do |value|
323
+ case value
324
+ when Hash then normalize_keys(value)
325
+ else value
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Schema
5
+ # Registers event classes at Rails startup to ensure RailsEventStore
6
+ # can properly deserialize events. Without this, dynamically created
7
+ # event classes would not exist after app restart, causing
8
+ # Object.const_get() to fail during event deserialization.
9
+ class EventClassRegistrar
10
+ class << self
11
+ # Register all event classes from configuration and stored schema
12
+ def register_all
13
+ register_from_configuration
14
+ register_from_schema
15
+ end
16
+
17
+ # Get list of all registered event class names
18
+ def registered_events
19
+ Lyra::Events.constants(false).map(&:to_s)
20
+ end
21
+
22
+ private
23
+
24
+ # Register events for currently monitored models
25
+ def register_from_configuration
26
+ Lyra.config.monitored_models.each do |model_class|
27
+ config = Lyra.config.model_config(model_class)
28
+
29
+ [:created, :updated, :destroyed].each do |operation|
30
+ event_name = config.event_name_for(operation)
31
+ ensure_event_class_exists(event_name)
32
+ end
33
+ end
34
+ end
35
+
36
+ # Register events from stored schema (handles legacy/removed models)
37
+ def register_from_schema
38
+ return unless Store.exists?
39
+
40
+ schema = Store.load_current
41
+ return unless schema && schema[:models]
42
+
43
+ schema[:models].each_value do |model_schema|
44
+ events = model_schema[:events] || model_schema["events"] || {}
45
+ events.each_key do |event_name|
46
+ ensure_event_class_exists(event_name.to_s)
47
+ end
48
+ end
49
+ end
50
+
51
+ def ensure_event_class_exists(event_name)
52
+ # Sanitize namespaced event names (e.g., "Spree::OrderCreated" -> "SpreeOrderCreated")
53
+ # Ruby const_set doesn't accept "::" in constant names
54
+ sanitized_name = event_name.to_s.gsub("::", "")
55
+
56
+ return if Lyra::Events.const_defined?(sanitized_name, false)
57
+
58
+ Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end