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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +222 -0
- data/LICENSE +21 -0
- data/README.md +1165 -0
- data/Rakefile +728 -0
- data/app/controllers/lyra/application_controller.rb +23 -0
- data/app/controllers/lyra/dashboard_controller.rb +624 -0
- data/app/controllers/lyra/flow_controller.rb +224 -0
- data/app/controllers/lyra/privacy_controller.rb +182 -0
- data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
- data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
- data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
- data/app/views/lyra/dashboard/index.html.erb +119 -0
- data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
- data/app/views/lyra/dashboard/projections.html.erb +302 -0
- data/app/views/lyra/dashboard/schema.html.erb +283 -0
- data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
- data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
- data/app/views/lyra/dashboard/verification.html.erb +370 -0
- data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
- data/app/views/lyra/flow/timeline.html.erb +260 -0
- data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
- data/app/views/lyra/privacy/policy.html.erb +188 -0
- data/app/workflows/es_async_mode_workflow.rb +80 -0
- data/app/workflows/es_sync_mode_workflow.rb +64 -0
- data/app/workflows/hijack_mode_workflow.rb +54 -0
- data/app/workflows/lifecycle_workflow.rb +43 -0
- data/app/workflows/monitor_mode_workflow.rb +39 -0
- data/config/privacy_policies.rb +273 -0
- data/config/routes.rb +48 -0
- data/lib/lyra/aggregate.rb +131 -0
- data/lib/lyra/associations/event_aware.rb +225 -0
- data/lib/lyra/command.rb +81 -0
- data/lib/lyra/command_handler.rb +155 -0
- data/lib/lyra/configuration.rb +124 -0
- data/lib/lyra/consistency/read_your_writes.rb +91 -0
- data/lib/lyra/correlation.rb +144 -0
- data/lib/lyra/dual_view.rb +231 -0
- data/lib/lyra/engine.rb +67 -0
- data/lib/lyra/event.rb +71 -0
- data/lib/lyra/event_analyzer.rb +135 -0
- data/lib/lyra/event_flow.rb +449 -0
- data/lib/lyra/event_mapper.rb +106 -0
- data/lib/lyra/event_store_adapter.rb +72 -0
- data/lib/lyra/id_generator.rb +137 -0
- data/lib/lyra/interceptors/association_interceptor.rb +169 -0
- data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
- data/lib/lyra/privacy/pii_detector.rb +85 -0
- data/lib/lyra/privacy/pii_masker.rb +66 -0
- data/lib/lyra/privacy/policy_integration.rb +253 -0
- data/lib/lyra/projection.rb +94 -0
- data/lib/lyra/projections/async_projection_job.rb +63 -0
- data/lib/lyra/projections/cached_projection.rb +322 -0
- data/lib/lyra/projections/cached_relation.rb +757 -0
- data/lib/lyra/projections/event_store_reader.rb +127 -0
- data/lib/lyra/projections/model_projection.rb +143 -0
- data/lib/lyra/schema/diff.rb +331 -0
- data/lib/lyra/schema/event_class_registrar.rb +63 -0
- data/lib/lyra/schema/generator.rb +190 -0
- data/lib/lyra/schema/reporter.rb +188 -0
- data/lib/lyra/schema/store.rb +156 -0
- data/lib/lyra/schema/validator.rb +100 -0
- data/lib/lyra/strict_data_access.rb +363 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
- data/lib/lyra/verification/workflow_generator.rb +540 -0
- data/lib/lyra/version.rb +3 -0
- data/lib/lyra/visualization/activity_heatmap.rb +215 -0
- data/lib/lyra/visualization/event_graph.rb +310 -0
- data/lib/lyra/visualization/timeline.rb +398 -0
- data/lib/lyra.rb +150 -0
- data/lib/tasks/dist.rake +391 -0
- data/lib/tasks/gems.rake +185 -0
- data/lib/tasks/lyra_schema.rake +231 -0
- data/lib/tasks/lyra_workflows.rake +452 -0
- data/lib/tasks/public_release.rake +351 -0
- data/lib/tasks/stats.rake +175 -0
- data/lib/tasks/testbed.rake +479 -0
- data/lib/tasks/version.rake +159 -0
- 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
|