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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lyra
6
+ module Schema
7
+ # Generates event schemas from monitored ActiveRecord models
8
+ class Generator
9
+ STANDARD_DATA_FIELDS = %w[model_class model_id operation attributes changes timestamp].freeze
10
+ STANDARD_METADATA_FIELDS = %w[user_id request_id correlation_id causation_id source].freeze
11
+
12
+ class << self
13
+ def generate
14
+ new.generate
15
+ end
16
+ end
17
+
18
+ def generate
19
+ schema = {
20
+ version: next_version,
21
+ created_at: Time.current.iso8601,
22
+ lyra_version: Lyra::VERSION,
23
+ rails_version: rails_version,
24
+ models: generate_models_schema,
25
+ configuration: generate_configuration_schema,
26
+ summary: nil # Placeholder, filled after models generated
27
+ }
28
+
29
+ schema[:summary] = generate_summary(schema[:models])
30
+ schema[:fingerprint] = compute_fingerprint(schema)
31
+
32
+ schema
33
+ end
34
+
35
+ private
36
+
37
+ def generate_models_schema
38
+ Lyra.config.monitored_models.each_with_object({}) do |model_class, hash|
39
+ hash[model_class.name] = generate_model_schema(model_class)
40
+ end
41
+ end
42
+
43
+ def generate_model_schema(model_class)
44
+ config = Lyra.config.model_config(model_class)
45
+
46
+ schema = {
47
+ table_name: safe_table_name(model_class),
48
+ event_prefix: config.event_prefix,
49
+ aggregate_class: config.aggregate_class&.name,
50
+ columns: generate_columns_schema(model_class),
51
+ events: generate_events_schema(model_class, config),
52
+ custom_events: extract_custom_events(config),
53
+ privacy_policy: config.privacy_policy&.to_s
54
+ }
55
+
56
+ schema
57
+ end
58
+
59
+ def generate_columns_schema(model_class)
60
+ return {} unless model_class.respond_to?(:columns)
61
+
62
+ model_class.columns.each_with_object({}) do |column, hash|
63
+ pii_info = detect_pii_for_column(column.name)
64
+
65
+ hash[column.name] = {
66
+ type: column.type.to_s,
67
+ nullable: column.null,
68
+ limit: column.limit,
69
+ default: safe_default(column.default),
70
+ primary_key: column.name == safe_primary_key(model_class),
71
+ pii: pii_info[:is_pii],
72
+ pii_type: pii_info[:type]&.to_s
73
+ }
74
+ end
75
+ end
76
+
77
+ def generate_events_schema(model_class, config)
78
+ [:created, :updated, :destroyed].each_with_object({}) do |operation, hash|
79
+ event_name = config.event_name_for(operation)
80
+ pii_fields = detect_pii_fields(model_class)
81
+
82
+ hash[event_name] = {
83
+ operation: operation.to_s,
84
+ data_fields: STANDARD_DATA_FIELDS,
85
+ metadata_fields: STANDARD_METADATA_FIELDS,
86
+ attribute_fields: safe_column_names(model_class),
87
+ pii_fields: pii_fields
88
+ }
89
+ end
90
+ end
91
+
92
+ def extract_custom_events(config)
93
+ # Access private instance variable for custom event mappings
94
+ custom_mapping = config.instance_variable_get(:@custom_event_mapping) || {}
95
+ custom_mapping.transform_keys(&:to_s).transform_values(&:to_s)
96
+ end
97
+
98
+ def detect_pii_for_column(column_name)
99
+ return { is_pii: false, type: nil } unless Lyra.privacy_features_available?
100
+
101
+ is_pii = Lyra::Privacy::PIIDetector.contains_pii?(column_name)
102
+
103
+ if is_pii
104
+ # Get the PII type by detecting on a dummy hash
105
+ pii_info = Lyra::Privacy::PIIDetector.detect({ column_name.to_sym => nil })
106
+ type = pii_info.dig(column_name.to_sym, :type)
107
+ { is_pii: true, type: type }
108
+ else
109
+ { is_pii: false, type: nil }
110
+ end
111
+ end
112
+
113
+ def detect_pii_fields(model_class)
114
+ return [] unless Lyra.privacy_features_available?
115
+ return [] unless model_class.respond_to?(:column_names)
116
+
117
+ model_class.column_names.select do |name|
118
+ Lyra::Privacy::PIIDetector.contains_pii?(name)
119
+ end
120
+ end
121
+
122
+ def generate_configuration_schema
123
+ config = Lyra.config
124
+ {
125
+ mode: config.mode.to_s,
126
+ strict_schema: config.strict_schema,
127
+ projection_mode: config.projection_mode.to_s,
128
+ retention_policy: config.retention_policy
129
+ }
130
+ end
131
+
132
+ def generate_summary(models)
133
+ total_columns = 0
134
+ total_pii = 0
135
+ total_events = 0
136
+
137
+ models.each_value do |model|
138
+ total_columns += model[:columns]&.size || 0
139
+ total_pii += model[:columns]&.count { |_, c| c[:pii] } || 0
140
+ total_events += model[:events]&.size || 0
141
+ end
142
+
143
+ {
144
+ models_count: models.size,
145
+ total_columns: total_columns,
146
+ pii_fields_count: total_pii,
147
+ events_count: total_events
148
+ }
149
+ end
150
+
151
+ def compute_fingerprint(schema)
152
+ # Exclude version, timestamps, and fingerprint from hash calculation
153
+ content = schema.except(:fingerprint, :version, :created_at).to_yaml
154
+ "sha256:#{Digest::SHA256.hexdigest(content)}"
155
+ end
156
+
157
+ def next_version
158
+ Store.latest_version + 1
159
+ end
160
+
161
+ def rails_version
162
+ defined?(Rails::VERSION::STRING) ? Rails::VERSION::STRING : "unknown"
163
+ end
164
+
165
+ def safe_table_name(model_class)
166
+ model_class.respond_to?(:table_name) ? model_class.table_name : nil
167
+ end
168
+
169
+ def safe_primary_key(model_class)
170
+ model_class.respond_to?(:primary_key) ? model_class.primary_key : "id"
171
+ end
172
+
173
+ def safe_column_names(model_class)
174
+ model_class.respond_to?(:column_names) ? model_class.column_names : []
175
+ end
176
+
177
+ def safe_default(default_value)
178
+ # Convert Proc defaults to string representation
179
+ case default_value
180
+ when Proc
181
+ "(dynamic)"
182
+ when nil
183
+ nil
184
+ else
185
+ default_value.to_s
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Schema
5
+ # Generates human-readable reports of model->event mappings
6
+ class Reporter
7
+ class << self
8
+ def generate
9
+ new.generate
10
+ end
11
+
12
+ def to_s
13
+ new.to_s
14
+ end
15
+ end
16
+
17
+ def generate
18
+ schema = Store.load_current || Generator.generate
19
+
20
+ {
21
+ header: generate_header(schema),
22
+ models: generate_models_report(schema),
23
+ events: generate_events_report(schema),
24
+ pii: generate_pii_report(schema),
25
+ configuration: schema[:configuration]
26
+ }
27
+ end
28
+
29
+ def to_s
30
+ report = generate
31
+ format_report(report)
32
+ end
33
+
34
+ private
35
+
36
+ def format_report(report)
37
+ lines = []
38
+
39
+ # Header
40
+ lines << "=" * 70
41
+ lines << center_text("Lyra Event Schema Report", 70)
42
+ lines << "=" * 70
43
+ lines << ""
44
+
45
+ # Version info
46
+ if report[:header][:version]
47
+ lines << "Schema Version: v#{report[:header][:version]}"
48
+ lines << "Created: #{report[:header][:created_at]}"
49
+ else
50
+ lines << "Schema: (not yet saved - showing current configuration)"
51
+ end
52
+ lines << "Lyra Version: #{report[:header][:lyra_version]}"
53
+ lines << ""
54
+
55
+ # Configuration
56
+ lines << "-" * 70
57
+ lines << "CONFIGURATION"
58
+ lines << "-" * 70
59
+ config = report[:configuration] || {}
60
+ lines << " Mode: #{config[:mode] || 'unknown'}"
61
+ lines << " Strict Schema: #{config[:strict_schema]}"
62
+ lines << " Projection Mode: #{config[:projection_mode]}"
63
+ lines << ""
64
+
65
+ # Model mappings
66
+ lines << "-" * 70
67
+ lines << "MODEL -> EVENT MAPPINGS"
68
+ lines << "-" * 70
69
+
70
+ report[:models].each do |model_name, model_info|
71
+ lines << ""
72
+ lines << "#{model_name}"
73
+ lines << " Table: #{model_info[:table_name]}"
74
+ lines << " Event Prefix: #{model_info[:event_prefix]}"
75
+ lines << " Columns: #{model_info[:column_count]}"
76
+
77
+ if model_info[:pii_fields].any?
78
+ lines << " PII Fields: #{model_info[:pii_fields].join(', ')}"
79
+ end
80
+
81
+ lines << ""
82
+ lines << " Events:"
83
+ model_info[:events].each do |event_name, event_info|
84
+ pii_note = event_info[:pii_fields]&.any? ? " [PII]" : ""
85
+ lines << " -> #{event_name} (#{event_info[:operation]})#{pii_note}"
86
+ end
87
+ end
88
+
89
+ # PII Summary
90
+ if report[:pii].any?
91
+ lines << ""
92
+ lines << "-" * 70
93
+ lines << "PII SUMMARY BY TYPE"
94
+ lines << "-" * 70
95
+ lines << ""
96
+
97
+ report[:pii].each do |type, fields|
98
+ lines << " #{type}:"
99
+ fields.each { |f| lines << " - #{f}" }
100
+ end
101
+ end
102
+
103
+ # Events Summary
104
+ lines << ""
105
+ lines << "-" * 70
106
+ lines << "ALL EVENTS"
107
+ lines << "-" * 70
108
+ lines << ""
109
+ lines << " %-30s %-20s %-12s" % ["Event Name", "Model", "Operation"]
110
+ lines << " " + "-" * 62
111
+
112
+ report[:events].each do |event|
113
+ lines << " %-30s %-20s %-12s" % [
114
+ event[:event_name],
115
+ event[:model],
116
+ event[:operation]
117
+ ]
118
+ end
119
+
120
+ lines << ""
121
+ lines << "=" * 70
122
+
123
+ lines.join("\n")
124
+ end
125
+
126
+ def generate_header(schema)
127
+ {
128
+ version: schema[:version],
129
+ created_at: schema[:created_at],
130
+ lyra_version: schema[:lyra_version],
131
+ fingerprint: schema[:fingerprint]
132
+ }
133
+ end
134
+
135
+ def generate_models_report(schema)
136
+ (schema[:models] || {}).transform_values do |model|
137
+ pii_fields = (model[:columns] || {}).select { |_, c| c[:pii] }.keys
138
+
139
+ {
140
+ table_name: model[:table_name],
141
+ event_prefix: model[:event_prefix],
142
+ aggregate_class: model[:aggregate_class],
143
+ column_count: model[:columns]&.size || 0,
144
+ pii_fields: pii_fields,
145
+ events: model[:events] || {}
146
+ }
147
+ end
148
+ end
149
+
150
+ def generate_events_report(schema)
151
+ events = []
152
+
153
+ (schema[:models] || {}).each do |model_name, model|
154
+ (model[:events] || {}).each do |event_name, event_info|
155
+ events << {
156
+ event_name: event_name,
157
+ model: model_name,
158
+ operation: event_info[:operation],
159
+ pii_fields: event_info[:pii_fields]
160
+ }
161
+ end
162
+ end
163
+
164
+ events.sort_by { |e| [e[:model], e[:operation]] }
165
+ end
166
+
167
+ def generate_pii_report(schema)
168
+ pii_by_type = Hash.new { |h, k| h[k] = [] }
169
+
170
+ (schema[:models] || {}).each do |model_name, model|
171
+ (model[:columns] || {}).each do |col_name, col_info|
172
+ next unless col_info[:pii]
173
+
174
+ pii_type = col_info[:pii_type] || "unknown"
175
+ pii_by_type[pii_type] << "#{model_name}.#{col_name}"
176
+ end
177
+ end
178
+
179
+ pii_by_type.sort.to_h
180
+ end
181
+
182
+ def center_text(text, width)
183
+ padding = [(width - text.length) / 2, 0].max
184
+ " " * padding + text
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Schema
5
+ # Handles schema file persistence and versioning
6
+ # Schemas are stored in db/lyra_schemas/ (like AR migrations)
7
+ class Store
8
+ DEFAULT_PATH = "db/lyra_schemas"
9
+
10
+ class << self
11
+ def schema_path
12
+ return @schema_path if @schema_path
13
+
14
+ custom_path = Lyra.config.schema_path
15
+ base_path = custom_path || DEFAULT_PATH
16
+
17
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
18
+ Rails.root.join(base_path)
19
+ else
20
+ Pathname.new(base_path)
21
+ end
22
+ end
23
+
24
+ def schema_path=(path)
25
+ @schema_path = path ? Pathname.new(path) : nil
26
+ end
27
+
28
+ def reset_path!
29
+ @schema_path = nil
30
+ end
31
+
32
+ def ensure_directory!
33
+ FileUtils.mkdir_p(schema_path)
34
+ end
35
+
36
+ # Load the current (latest) schema
37
+ def load_current
38
+ current_file = schema_path.join("current.yml")
39
+ return nil unless current_file.exist?
40
+
41
+ load_yaml(current_file)
42
+ end
43
+
44
+ # Load a specific version
45
+ def load_version(version)
46
+ file = find_version_file(version)
47
+ return nil unless file&.exist?
48
+
49
+ load_yaml(file)
50
+ end
51
+
52
+ # Save a new schema version
53
+ def save(schema)
54
+ ensure_directory!
55
+
56
+ version = schema[:version]
57
+ timestamp = Time.current.strftime("%Y%m%d%H%M%S")
58
+ filename = "v#{version}_#{timestamp}.yml"
59
+
60
+ file_path = schema_path.join(filename)
61
+ file_path.write(schema.deep_stringify_keys.to_yaml)
62
+
63
+ # Update current.yml
64
+ update_current(filename)
65
+
66
+ file_path
67
+ end
68
+
69
+ # List all schema versions
70
+ def versions
71
+ return [] unless schema_path.exist?
72
+
73
+ schema_path.glob("v*.yml")
74
+ .reject { |f| f.basename.to_s == "current.yml" }
75
+ .map { |f| extract_version(f) }
76
+ .compact
77
+ .sort
78
+ end
79
+
80
+ def latest_version
81
+ versions.max || 0
82
+ end
83
+
84
+ # Get version history with metadata
85
+ def history
86
+ return [] unless schema_path.exist?
87
+
88
+ schema_path.glob("v*.yml")
89
+ .reject { |f| f.basename.to_s == "current.yml" }
90
+ .map do |file|
91
+ schema = load_yaml(file)
92
+ next nil unless schema
93
+
94
+ {
95
+ version: schema[:version],
96
+ created_at: schema[:created_at],
97
+ lyra_version: schema[:lyra_version],
98
+ fingerprint: schema[:fingerprint],
99
+ file: file.basename.to_s,
100
+ models_count: schema.dig(:summary, :models_count)
101
+ }
102
+ end
103
+ .compact
104
+ .sort_by { |h| h[:version] }
105
+ end
106
+
107
+ # Check if any schema exists
108
+ def exists?
109
+ schema_path.exist? && schema_path.join("current.yml").exist?
110
+ end
111
+
112
+ private
113
+
114
+ def find_version_file(version)
115
+ return nil unless schema_path.exist?
116
+
117
+ schema_path.glob("v#{version}_*.yml").first
118
+ end
119
+
120
+ def extract_version(file)
121
+ match = file.basename.to_s.match(/^v(\d+)_/)
122
+ match ? match[1].to_i : nil
123
+ end
124
+
125
+ def update_current(filename)
126
+ current_path = schema_path.join("current.yml")
127
+ source_path = schema_path.join(filename)
128
+
129
+ # Use copy instead of symlink for Windows compatibility
130
+ FileUtils.cp(source_path, current_path)
131
+ end
132
+
133
+ def load_yaml(file)
134
+ YAML.safe_load(
135
+ file.read,
136
+ permitted_classes: [Time, Date, DateTime, Symbol, ActiveSupport::Duration],
137
+ symbolize_names: true
138
+ )
139
+ rescue Psych::DisallowedClass => e
140
+ # Fallback: try loading with unsafe_load for schema files only
141
+ # This handles custom classes like ActiveSupport::Duration in retention_policy
142
+ Rails.logger.debug("Lyra: Schema file contains unknown class, using unsafe load: #{e.message}") if defined?(Rails)
143
+ begin
144
+ YAML.unsafe_load(file.read, symbolize_names: true)
145
+ rescue => inner_e
146
+ Rails.logger.warn("Lyra: Failed to load schema file #{file}: #{inner_e.message}") if defined?(Rails)
147
+ nil
148
+ end
149
+ rescue => e
150
+ Rails.logger.warn("Lyra: Failed to load schema file #{file}: #{e.message}") if defined?(Rails)
151
+ nil
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Schema
5
+ # Validates current schema against stored schema
6
+ class Validator
7
+ attr_reader :current_schema, :stored_schema, :differences
8
+
9
+ def initialize
10
+ @current_schema = nil
11
+ @stored_schema = nil
12
+ @differences = []
13
+ @validated = false
14
+ end
15
+
16
+ # Check if schema is valid (no differences)
17
+ def valid?
18
+ validate! unless @validated
19
+ @differences.empty?
20
+ end
21
+
22
+ # Check if stored schema exists
23
+ def schema_exists?
24
+ Store.exists?
25
+ end
26
+
27
+ # Check if there are breaking changes
28
+ def breaking_changes?
29
+ validate! unless @validated
30
+ @differences.any? { |d| d[:severity] == :breaking }
31
+ end
32
+
33
+ # Generate human-readable report
34
+ def report
35
+ validate! unless @validated
36
+ Diff.format_report(@differences)
37
+ end
38
+
39
+ # Perform validation
40
+ def validate!
41
+ @validated = true
42
+ @stored_schema = Store.load_current
43
+ @current_schema = Generator.generate
44
+
45
+ return true if @stored_schema.nil? # No schema yet = valid
46
+
47
+ @differences = Diff.compare(@stored_schema, @current_schema)
48
+ @differences.empty?
49
+ end
50
+
51
+ # Validate and enforce strict mode if configured
52
+ def enforce!
53
+ return true if valid?
54
+
55
+ if Lyra.config.strict_schema
56
+ raise SchemaValidationError.new(report, @differences)
57
+ else
58
+ log_warning
59
+ false
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def log_warning
66
+ message = "Lyra: Schema drift detected\n#{report}"
67
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
68
+ Rails.logger.warn(message)
69
+ else
70
+ warn message
71
+ end
72
+ end
73
+ end
74
+
75
+ # Custom error for schema validation failures
76
+ class SchemaValidationError < StandardError
77
+ attr_reader :differences
78
+
79
+ def initialize(message, differences = [])
80
+ @differences = differences
81
+ super(build_message(message))
82
+ end
83
+
84
+ private
85
+
86
+ def build_message(report)
87
+ <<~MSG
88
+ Lyra Event Schema Validation Failed!
89
+
90
+ #{report}
91
+
92
+ To fix this issue, either:
93
+ 1. Run 'rake lyra:schema:update' to create a new schema version
94
+ 2. Set 'Lyra.config.strict_schema = false' to allow schema drift
95
+ 3. Review the changes and ensure they are intentional
96
+ MSG
97
+ end
98
+ end
99
+ end
100
+ end