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,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
|