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,215 @@
1
+ module Lyra
2
+ module Visualization
3
+ # Activity heatmap visualization for D3.js
4
+ # Shows event activity patterns across time (hour of day vs day of week)
5
+ class ActivityHeatmap
6
+ DAYS_OF_WEEK = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
7
+ HOURS_OF_DAY = (0..23).to_a.freeze
8
+
9
+ def initialize(events)
10
+ @events = events.sort_by { |e| event_timestamp(e) }
11
+ end
12
+
13
+ # Generate heatmap data for D3.js
14
+ def to_d3_json
15
+ {
16
+ data: build_heatmap_data,
17
+ x_labels: hour_labels,
18
+ y_labels: DAYS_OF_WEEK,
19
+ metadata: heatmap_metadata
20
+ }.to_json
21
+ end
22
+
23
+ # Generate data hash (for API responses)
24
+ def to_data
25
+ {
26
+ data: build_heatmap_data,
27
+ x_labels: hour_labels,
28
+ y_labels: DAYS_OF_WEEK,
29
+ metadata: heatmap_metadata
30
+ }
31
+ end
32
+
33
+ # Generate hourly breakdown for a specific model
34
+ def hourly_breakdown(model_class = nil)
35
+ filtered = model_class ? @events.select { |e| event_model_class(e) == model_class } : @events
36
+
37
+ HOURS_OF_DAY.map do |hour|
38
+ count = filtered.count { |e| event_timestamp(e).hour == hour }
39
+ {
40
+ hour: hour,
41
+ label: format_hour(hour),
42
+ count: count,
43
+ percentage: filtered.empty? ? 0 : (count.to_f / filtered.size * 100).round(2)
44
+ }
45
+ end
46
+ end
47
+
48
+ # Generate daily breakdown
49
+ def daily_breakdown(model_class = nil)
50
+ filtered = model_class ? @events.select { |e| event_model_class(e) == model_class } : @events
51
+
52
+ DAYS_OF_WEEK.each_with_index.map do |day, i|
53
+ count = filtered.count { |e| event_timestamp(e).wday == i }
54
+ {
55
+ day: day,
56
+ day_index: i,
57
+ count: count,
58
+ percentage: filtered.empty? ? 0 : (count.to_f / filtered.size * 100).round(2)
59
+ }
60
+ end
61
+ end
62
+
63
+ # Generate operation-based heatmap
64
+ def operation_heatmap
65
+ operations = @events.map { |e| event_operation(e) }.uniq
66
+
67
+ operations.map do |operation|
68
+ op_events = @events.select { |e| event_operation(e) == operation }
69
+ {
70
+ operation: operation.to_s,
71
+ hourly: HOURS_OF_DAY.map { |h| op_events.count { |e| event_timestamp(e).hour == h } },
72
+ total: op_events.count
73
+ }
74
+ end
75
+ end
76
+
77
+ # Generate model-based heatmap
78
+ def model_heatmap
79
+ model_classes = @events.map { |e| event_model_class(e) }.uniq
80
+
81
+ model_classes.map do |model_class|
82
+ model_events = @events.select { |e| event_model_class(e) == model_class }
83
+ {
84
+ model_class: model_class,
85
+ hourly: HOURS_OF_DAY.map { |h| model_events.count { |e| event_timestamp(e).hour == h } },
86
+ daily: DAYS_OF_WEEK.each_with_index.map { |_d, i| model_events.count { |e| event_timestamp(e).wday == i } },
87
+ total: model_events.count
88
+ }
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def build_heatmap_data
95
+ # Create a 7x24 matrix (days x hours)
96
+ matrix = Array.new(7) { Array.new(24, 0) }
97
+
98
+ @events.each do |event|
99
+ ts = event_timestamp(event)
100
+ day = ts.wday
101
+ hour = ts.hour
102
+ matrix[day][hour] += 1
103
+ end
104
+
105
+ # Convert to D3.js-friendly format
106
+ data = []
107
+ matrix.each_with_index do |day_data, day_index|
108
+ day_data.each_with_index do |count, hour|
109
+ data << {
110
+ day: day_index,
111
+ day_name: DAYS_OF_WEEK[day_index],
112
+ hour: hour,
113
+ hour_label: format_hour(hour),
114
+ count: count,
115
+ intensity: calculate_intensity(count)
116
+ }
117
+ end
118
+ end
119
+
120
+ data
121
+ end
122
+
123
+ def hour_labels
124
+ HOURS_OF_DAY.map { |h| format_hour(h) }
125
+ end
126
+
127
+ def format_hour(hour)
128
+ if hour == 0
129
+ "12 AM"
130
+ elsif hour < 12
131
+ "#{hour} AM"
132
+ elsif hour == 12
133
+ "12 PM"
134
+ else
135
+ "#{hour - 12} PM"
136
+ end
137
+ end
138
+
139
+ def calculate_intensity(count)
140
+ return 0 if @events.empty?
141
+
142
+ max_count = @events.group_by { |e| ts = event_timestamp(e); [ts.wday, ts.hour] }
143
+ .values.map(&:count).max || 1
144
+
145
+ (count.to_f / max_count * 100).round(2)
146
+ end
147
+
148
+ def heatmap_metadata
149
+ {
150
+ total_events: @events.count,
151
+ time_range: {
152
+ start: @events.first ? event_timestamp(@events.first) : nil,
153
+ end: @events.last ? event_timestamp(@events.last) : nil
154
+ },
155
+ peak_hour: find_peak_hour,
156
+ peak_day: find_peak_day,
157
+ busiest_slot: find_busiest_slot,
158
+ operations_breakdown: @events.group_by { |e| event_operation(e) }.transform_values(&:count),
159
+ models_breakdown: @events.group_by { |e| event_model_class(e) }.transform_values(&:count)
160
+ }
161
+ end
162
+
163
+ def find_peak_hour
164
+ return nil if @events.empty?
165
+
166
+ hourly_counts = @events.group_by { |e| event_timestamp(e).hour }
167
+ .transform_values(&:count)
168
+ peak = hourly_counts.max_by { |_h, c| c }
169
+ { hour: peak[0], label: format_hour(peak[0]), count: peak[1] }
170
+ end
171
+
172
+ def find_peak_day
173
+ return nil if @events.empty?
174
+
175
+ daily_counts = @events.group_by { |e| event_timestamp(e).wday }
176
+ .transform_values(&:count)
177
+ peak = daily_counts.max_by { |_d, c| c }
178
+ { day: peak[0], label: DAYS_OF_WEEK[peak[0]], count: peak[1] }
179
+ end
180
+
181
+ def find_busiest_slot
182
+ return nil if @events.empty?
183
+
184
+ slots = @events.group_by { |e| ts = event_timestamp(e); [ts.wday, ts.hour] }
185
+ .transform_values(&:count)
186
+ peak = slots.max_by { |_slot, c| c }
187
+ day, hour = peak[0]
188
+ {
189
+ day: day,
190
+ day_label: DAYS_OF_WEEK[day],
191
+ hour: hour,
192
+ hour_label: format_hour(hour),
193
+ count: peak[1]
194
+ }
195
+ end
196
+
197
+ # Helper methods to handle both Lyra::Event and RubyEventStore::Event
198
+ def event_model_class(event)
199
+ return event.model_class if event.respond_to?(:model_class)
200
+ event.data[:model_class] || event.data["model_class"]
201
+ end
202
+
203
+ def event_operation(event)
204
+ return event.operation if event.respond_to?(:operation)
205
+ op = event.data[:operation] || event.data["operation"]
206
+ op.is_a?(String) ? op.to_sym : op
207
+ end
208
+
209
+ def event_timestamp(event)
210
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
211
+ event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp] || Time.now
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,310 @@
1
+ module Lyra
2
+ module Visualization
3
+ # Force-directed event graph visualization for D3.js
4
+ class EventGraph
5
+ def initialize(events)
6
+ @events = events.sort_by { |e| event_timestamp(e) }
7
+ end
8
+
9
+ # Generate graph data for D3.js force-directed layout
10
+ def to_d3_json
11
+ {
12
+ nodes: build_nodes,
13
+ links: build_links,
14
+ metadata: graph_metadata
15
+ }.to_json
16
+ end
17
+
18
+ # Generate data hash (for API responses)
19
+ def to_data
20
+ {
21
+ nodes: build_nodes,
22
+ links: build_links,
23
+ metadata: graph_metadata
24
+ }
25
+ end
26
+
27
+ # Generate Mermaid flowchart
28
+ def to_mermaid
29
+ lines = ["flowchart TD"]
30
+
31
+ @events.each do |event|
32
+ node_id = sanitize_id(event_id(event))
33
+ operation = event_operation(event).to_s.upcase
34
+ model_info = "#{event_model_class(event)}##{event_model_id(event)}"
35
+ changed_fields = format_changed_fields(event)
36
+
37
+ label = if changed_fields.present?
38
+ "#{operation}\\n#{model_info}\\n#{changed_fields}"
39
+ else
40
+ "#{operation}\\n#{model_info}"
41
+ end
42
+
43
+ lines << " #{node_id}[\"#{label}\"]"
44
+ end
45
+
46
+ lines << ""
47
+
48
+ build_links.each do |link|
49
+ source_id = sanitize_id(link[:source])
50
+ target_id = sanitize_id(link[:target])
51
+ link_style = link[:type] == 'correlation' ? '-->' : '-.->'
52
+ lines << " #{source_id} #{link_style} #{target_id}"
53
+ end
54
+
55
+ lines.join("\n")
56
+ end
57
+
58
+ private
59
+
60
+ def build_nodes
61
+ model_groups = @events.map { |e| event_model_class(e) }.uniq
62
+ color_scale = generate_color_scale(model_groups)
63
+
64
+ @events.map.with_index do |event, i|
65
+ has_pii = detect_pii?(event)
66
+ model_class = event_model_class(event)
67
+ model_id = event_model_id(event)
68
+ timestamp = event_timestamp(event)
69
+ metadata = event_metadata(event)
70
+ changes = event_changes(event)
71
+ changed_fields = extract_changed_field_names(changes)
72
+
73
+ {
74
+ id: event_id(event),
75
+ index: i,
76
+ label: "#{model_class}##{model_id}",
77
+ operation: event_operation(event).to_s,
78
+ model_class: model_class,
79
+ model_id: model_id,
80
+ timestamp: timestamp.to_i * 1000, # JavaScript timestamp
81
+ timestamp_formatted: timestamp.strftime("%Y-%m-%d %H:%M:%S"),
82
+ has_pii: has_pii,
83
+ group: model_groups.index(model_class),
84
+ color: color_scale[model_class],
85
+ size: calculate_node_size(event),
86
+ user_id: metadata[:user_id],
87
+ correlation_id: metadata[:correlation_id],
88
+ changed_fields: changed_fields,
89
+ changes: format_changes_for_display(changes)
90
+ }
91
+ end
92
+ end
93
+
94
+ def build_links
95
+ links = []
96
+ events_by_id = @events.index_by { |e| event_id(e) }
97
+
98
+ # Link events by correlation ID (same workflow/transaction)
99
+ @events.group_by { |e| event_metadata(e)[:correlation_id] }.each do |corr_id, group|
100
+ next if corr_id.nil? || group.size < 2
101
+
102
+ sorted = group.sort_by { |e| event_timestamp(e) }
103
+ sorted.each_cons(2) do |source, target|
104
+ links << {
105
+ source: event_id(source),
106
+ target: event_id(target),
107
+ type: 'correlation',
108
+ strength: 1.0,
109
+ label: 'correlated'
110
+ }
111
+ end
112
+ end
113
+
114
+ # Link events on same record (entity lifecycle)
115
+ @events.group_by { |e| "#{event_model_class(e)}-#{event_model_id(e)}" }.each do |_key, group|
116
+ next if group.size < 2
117
+
118
+ sorted = group.sort_by { |e| event_timestamp(e) }
119
+ sorted.each_cons(2) do |source, target|
120
+ links << {
121
+ source: event_id(source),
122
+ target: event_id(target),
123
+ type: 'same_record',
124
+ strength: 0.5,
125
+ label: 'same entity'
126
+ }
127
+ end
128
+ end
129
+
130
+ # Link events by same user (user activity pattern)
131
+ @events.group_by { |e| event_metadata(e)[:user_id] }.each do |user_id, group|
132
+ next if user_id.nil? || group.size < 2
133
+
134
+ sorted = group.sort_by { |e| event_timestamp(e) }
135
+ sorted.each_cons(2) do |source, target|
136
+ # Only link if within 5 minutes of each other
137
+ if (event_timestamp(target) - event_timestamp(source)) < 300
138
+ links << {
139
+ source: event_id(source),
140
+ target: event_id(target),
141
+ type: 'same_user',
142
+ strength: 0.3,
143
+ label: 'same user'
144
+ }
145
+ end
146
+ end
147
+ end
148
+
149
+ links.uniq { |l| [l[:source], l[:target]] }
150
+ end
151
+
152
+ def graph_metadata
153
+ {
154
+ total_nodes: @events.count,
155
+ total_links: build_links.count,
156
+ model_classes: @events.map { |e| event_model_class(e) }.uniq,
157
+ time_range: {
158
+ start: @events.first ? event_timestamp(@events.first) : nil,
159
+ end: @events.last ? event_timestamp(@events.last) : nil
160
+ },
161
+ operations: @events.group_by { |e| event_operation(e) }.transform_values(&:count),
162
+ pii_count: @events.count { |e| detect_pii?(e) }
163
+ }
164
+ end
165
+
166
+ def detect_pii?(event)
167
+ return false unless Lyra.privacy_features_available?
168
+
169
+ Lyra::Privacy::PIIDetector.detect(event_attributes(event)).any?
170
+ rescue
171
+ false
172
+ end
173
+
174
+ def calculate_node_size(event)
175
+ base_size = 10
176
+ # Larger nodes for events with more changes
177
+ changes = event_changes(event)
178
+ change_bonus = (changes&.size || 0) * 2
179
+ # Larger nodes for PII-containing events
180
+ pii_bonus = detect_pii?(event) ? 5 : 0
181
+
182
+ [base_size + change_bonus + pii_bonus, 30].min
183
+ end
184
+
185
+ def generate_color_scale(model_classes)
186
+ colors = %w[
187
+ #667eea #764ba2 #f59e0b #10b981 #ef4444
188
+ #8b5cf6 #06b6d4 #ec4899 #84cc16 #f97316
189
+ ]
190
+
191
+ model_classes.each_with_index.to_h do |model_class, i|
192
+ [model_class, colors[i % colors.length]]
193
+ end
194
+ end
195
+
196
+ def sanitize_id(id)
197
+ id.to_s.gsub(/[^a-zA-Z0-9]/, '_')
198
+ end
199
+
200
+ # Helper methods to handle both Lyra::Event and RubyEventStore::Event
201
+ def event_id(event)
202
+ return event.event_id if event.respond_to?(:event_id)
203
+ event.data[:event_id] || event.data["event_id"]
204
+ end
205
+
206
+ def event_model_class(event)
207
+ return event.model_class if event.respond_to?(:model_class)
208
+ event.data[:model_class] || event.data["model_class"]
209
+ end
210
+
211
+ def event_model_id(event)
212
+ return event.model_id if event.respond_to?(:model_id)
213
+ event.data[:model_id] || event.data["model_id"]
214
+ end
215
+
216
+ def event_operation(event)
217
+ return event.operation if event.respond_to?(:operation)
218
+ op = event.data[:operation] || event.data["operation"]
219
+ op.is_a?(String) ? op.to_sym : op
220
+ end
221
+
222
+ def event_timestamp(event)
223
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
224
+ event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp] || Time.now
225
+ end
226
+
227
+ def event_attributes(event)
228
+ return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Hash)
229
+ event.data[:attributes] || event.data["attributes"] || {}
230
+ end
231
+
232
+ def event_changes(event)
233
+ return event.changes if event.respond_to?(:changes) && event.method(:changes).owner != ActiveRecord::AttributeMethods::Dirty
234
+ event.data[:changes] || event.data["changes"] || {}
235
+ end
236
+
237
+ def event_metadata(event)
238
+ return event.metadata if event.respond_to?(:metadata)
239
+ {}
240
+ end
241
+
242
+ # Extract just the field names that changed
243
+ def extract_changed_field_names(changes)
244
+ return [] if changes.blank?
245
+
246
+ changes.keys.map(&:to_s).reject { |k| k.end_with?("_at") }
247
+ end
248
+
249
+ # Format changes for display in node details (with before/after values)
250
+ def format_changes_for_display(changes)
251
+ return [] if changes.blank?
252
+
253
+ changes.map do |field, values|
254
+ field_name = field.to_s
255
+ # Skip timestamp fields for cleaner display
256
+ next if field_name.end_with?("_at")
257
+
258
+ result = if values.is_a?(Array) && values.size == 2
259
+ { field: field_name, from: truncate_value(values[0]), to: truncate_value(values[1]) }
260
+ else
261
+ { field: field_name, value: truncate_value(values) }
262
+ end
263
+
264
+ # Add PII type if field contains PII
265
+ pii_type = detect_field_pii_type(field_name)
266
+ result[:pii_type] = pii_type if pii_type
267
+
268
+ result
269
+ end.compact
270
+ end
271
+
272
+ # Detect PII type for a specific field
273
+ def detect_field_pii_type(field_name)
274
+ return nil unless Lyra.privacy_features_available?
275
+
276
+ # Check if field name matches known PII patterns
277
+ pii_info = Lyra::Privacy::PIIDetector.detect({ field_name.to_sym => "sample" })
278
+ return nil if pii_info.empty?
279
+
280
+ pii_info.values.first[:type]&.to_s
281
+ rescue
282
+ nil
283
+ end
284
+
285
+ # Format changed fields for Mermaid label (compact)
286
+ def format_changed_fields(event)
287
+ changes = event_changes(event)
288
+ return nil if changes.blank?
289
+
290
+ fields = changes.keys.map(&:to_s).reject { |k| k.end_with?("_at") }
291
+ return nil if fields.empty?
292
+
293
+ # Limit to 3 fields to keep labels readable
294
+ if fields.size > 3
295
+ "📝 #{fields.first(3).join(', ')}..."
296
+ else
297
+ "📝 #{fields.join(', ')}"
298
+ end
299
+ end
300
+
301
+ # Truncate long values for display
302
+ def truncate_value(value)
303
+ return nil if value.nil?
304
+
305
+ str = value.to_s
306
+ str.length > 30 ? "#{str[0..27]}..." : str
307
+ end
308
+ end
309
+ end
310
+ end