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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
module Consistency
|
|
5
|
+
# Read-Your-Writes consistency helper for event_sourcing mode.
|
|
6
|
+
#
|
|
7
|
+
# When using async projections, there's a window where a record might
|
|
8
|
+
# exist in the event store but not yet in the database. This helper
|
|
9
|
+
# ensures that within a block, any writes are immediately projected
|
|
10
|
+
# before reads happen.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# Lyra::Consistency::ReadYourWrites.with_guaranteed_read do
|
|
14
|
+
# @registration = Registration.create!(params)
|
|
15
|
+
# redirect_to @registration # Guaranteed to find it
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
module ReadYourWrites
|
|
19
|
+
class << self
|
|
20
|
+
# Execute block with read-your-writes guarantee
|
|
21
|
+
#
|
|
22
|
+
# Tracks any writes within the block and ensures they are
|
|
23
|
+
# projected before the block returns.
|
|
24
|
+
def with_guaranteed_read
|
|
25
|
+
return yield unless Lyra.event_sourcing_mode?
|
|
26
|
+
|
|
27
|
+
# Store pending writes in thread-local storage
|
|
28
|
+
Thread.current[:lyra_pending_writes] = []
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
result = yield
|
|
32
|
+
ensure_projected
|
|
33
|
+
result
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[:lyra_pending_writes] = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Record a write that needs projection (called by interceptor)
|
|
40
|
+
def record_write(model_class, operation, result)
|
|
41
|
+
pending = Thread.current[:lyra_pending_writes]
|
|
42
|
+
return unless pending
|
|
43
|
+
|
|
44
|
+
pending << { model_class: model_class, operation: operation, result: result }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if we're in a guaranteed read block
|
|
48
|
+
def in_guaranteed_block?
|
|
49
|
+
!Thread.current[:lyra_pending_writes].nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Ensure all pending writes are projected
|
|
55
|
+
def ensure_projected
|
|
56
|
+
pending = Thread.current[:lyra_pending_writes] || []
|
|
57
|
+
|
|
58
|
+
pending.each do |write|
|
|
59
|
+
Lyra::Projections::ModelProjection.project(
|
|
60
|
+
write[:model_class],
|
|
61
|
+
write[:operation],
|
|
62
|
+
write[:result]
|
|
63
|
+
)
|
|
64
|
+
rescue => e
|
|
65
|
+
Rails.logger.error("Lyra: Failed to ensure projection - #{e.message}")
|
|
66
|
+
raise if Lyra.config.strict_projections
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Controller concern for automatic read-your-writes handling
|
|
73
|
+
module ControllerConcern
|
|
74
|
+
extend ActiveSupport::Concern
|
|
75
|
+
|
|
76
|
+
included do
|
|
77
|
+
around_action :with_lyra_consistency, if: :lyra_consistency_enabled?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def with_lyra_consistency
|
|
83
|
+
ReadYourWrites.with_guaranteed_read { yield }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def lyra_consistency_enabled?
|
|
87
|
+
Lyra.event_sourcing_mode? && Lyra.config.projection_mode == :async
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Correlation system for grouping related operations and events
|
|
3
|
+
# correlation_id: Groups all events from the same user action (e.g., all events from one HTTP request)
|
|
4
|
+
class Correlation
|
|
5
|
+
class << self
|
|
6
|
+
# Generate a unique correlation ID for a group of operations
|
|
7
|
+
def generate_id
|
|
8
|
+
"corr_#{Time.now.to_i}_#{SecureRandom.hex(8)}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Current correlation ID (thread-safe)
|
|
12
|
+
def current_id
|
|
13
|
+
Thread.current[:lyra_correlation_id]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Set correlation ID for current context
|
|
17
|
+
def with_id(correlation_id = nil)
|
|
18
|
+
correlation_id ||= generate_id
|
|
19
|
+
previous_id = Thread.current[:lyra_correlation_id]
|
|
20
|
+
Thread.current[:lyra_correlation_id] = correlation_id
|
|
21
|
+
|
|
22
|
+
yield correlation_id if block_given?
|
|
23
|
+
ensure
|
|
24
|
+
Thread.current[:lyra_correlation_id] = previous_id
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Causation system for tracking event chains
|
|
30
|
+
# causation_id: Points to the specific event that directly caused this event (parent-child relationship)
|
|
31
|
+
#
|
|
32
|
+
# Example:
|
|
33
|
+
# OrderPlaced (causation_id: nil) <- root event
|
|
34
|
+
# └── PaymentProcessed (causation_id: OrderPlaced.id)
|
|
35
|
+
# └── InventoryReserved (causation_id: PaymentProcessed.id)
|
|
36
|
+
#
|
|
37
|
+
class Causation
|
|
38
|
+
class << self
|
|
39
|
+
# Current causation ID (the event that caused the current operation)
|
|
40
|
+
def current_id
|
|
41
|
+
Thread.current[:lyra_causation_id]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Set causation ID for current context (when processing/handling an event)
|
|
45
|
+
def with_id(causation_id)
|
|
46
|
+
previous_id = Thread.current[:lyra_causation_id]
|
|
47
|
+
Thread.current[:lyra_causation_id] = causation_id
|
|
48
|
+
|
|
49
|
+
yield causation_id if block_given?
|
|
50
|
+
ensure
|
|
51
|
+
Thread.current[:lyra_causation_id] = previous_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clear causation (for root events)
|
|
55
|
+
def clear
|
|
56
|
+
Thread.current[:lyra_causation_id] = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Track causation relationship (for building causation chains)
|
|
60
|
+
def track(cause_event_id, effect_event_id)
|
|
61
|
+
causation_store[effect_event_id] = cause_event_id
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get the full causation chain for an event (root -> ... -> event)
|
|
65
|
+
def chain_for(event_id)
|
|
66
|
+
chain = [event_id]
|
|
67
|
+
current = event_id
|
|
68
|
+
|
|
69
|
+
while (parent = causation_store[current])
|
|
70
|
+
chain.unshift(parent)
|
|
71
|
+
current = parent
|
|
72
|
+
break if chain.size > 100 # Prevent infinite loops
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
chain
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get direct cause of an event
|
|
79
|
+
def cause_of(event_id)
|
|
80
|
+
causation_store[event_id]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def causation_store
|
|
86
|
+
@causation_store ||= {}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# User action context for tracking what user action triggered events
|
|
92
|
+
class UserActionContext
|
|
93
|
+
attr_reader :action_id, :user_id, :action_type, :controller, :action_name, :params
|
|
94
|
+
|
|
95
|
+
def initialize(action_type:, user_id: nil, controller: nil, action_name: nil, params: {})
|
|
96
|
+
@action_id = "action_#{Time.now.to_i}_#{SecureRandom.hex(6)}"
|
|
97
|
+
@action_type = action_type # :web_request, :api_call, :background_job, :console
|
|
98
|
+
@user_id = user_id
|
|
99
|
+
@controller = controller
|
|
100
|
+
@action_name = action_name
|
|
101
|
+
@params = sanitize_params(params)
|
|
102
|
+
@started_at = Time.current
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_h
|
|
106
|
+
{
|
|
107
|
+
action_id: action_id,
|
|
108
|
+
action_type: action_type,
|
|
109
|
+
user_id: user_id,
|
|
110
|
+
controller: controller,
|
|
111
|
+
action_name: action_name,
|
|
112
|
+
params: params,
|
|
113
|
+
started_at: @started_at
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class << self
|
|
118
|
+
def current
|
|
119
|
+
Thread.current[:lyra_user_action_context]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def with_context(action_type:, user_id: nil, **options)
|
|
123
|
+
context = new(action_type: action_type, user_id: user_id, **options)
|
|
124
|
+
previous = Thread.current[:lyra_user_action_context]
|
|
125
|
+
Thread.current[:lyra_user_action_context] = context
|
|
126
|
+
|
|
127
|
+
# Also set correlation ID
|
|
128
|
+
Lyra::Correlation.with_id(context.action_id) do
|
|
129
|
+
yield context if block_given?
|
|
130
|
+
end
|
|
131
|
+
ensure
|
|
132
|
+
Thread.current[:lyra_user_action_context] = previous
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def sanitize_params(params)
|
|
139
|
+
# Remove sensitive parameters
|
|
140
|
+
sensitive_keys = [:password, :password_confirmation, :token, :secret, :api_key]
|
|
141
|
+
params.except(*sensitive_keys)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Provides dual view comparison between CRUD state and Event-sourced state
|
|
3
|
+
class DualView
|
|
4
|
+
attr_reader :model_class, :model_id
|
|
5
|
+
|
|
6
|
+
def initialize(model_class, model_id)
|
|
7
|
+
@model_class = model_class
|
|
8
|
+
@model_id = model_id
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Get both CRUD and Event-sourced views
|
|
12
|
+
def compare
|
|
13
|
+
{
|
|
14
|
+
crud_view: crud_state,
|
|
15
|
+
event_sourced_view: event_sourced_state,
|
|
16
|
+
differences: calculate_differences,
|
|
17
|
+
metadata: {
|
|
18
|
+
model_class: model_class.name,
|
|
19
|
+
model_id: model_id,
|
|
20
|
+
timestamp: Time.current,
|
|
21
|
+
mode: Lyra.config.mode
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# CRUD view - current database state
|
|
27
|
+
def crud_state
|
|
28
|
+
record = model_class.find_by(id: model_id)
|
|
29
|
+
|
|
30
|
+
return { exists: false } unless record
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
exists: true,
|
|
34
|
+
attributes: record.attributes,
|
|
35
|
+
timestamps: {
|
|
36
|
+
created_at: record.created_at,
|
|
37
|
+
updated_at: record.updated_at
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Event-sourced view - state rebuilt from events
|
|
43
|
+
def event_sourced_state
|
|
44
|
+
stream_name = "#{model_class.name}$#{model_id}"
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
48
|
+
rescue => e
|
|
49
|
+
return {
|
|
50
|
+
exists: false,
|
|
51
|
+
error: e.message,
|
|
52
|
+
events_count: 0
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return { exists: false, events_count: 0 } if events.empty?
|
|
57
|
+
|
|
58
|
+
state = StateProjection.new.rebuild_from_events(events)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
exists: true,
|
|
62
|
+
state: state,
|
|
63
|
+
events_count: events.count,
|
|
64
|
+
first_event_at: event_timestamp(events.first),
|
|
65
|
+
last_event_at: event_timestamp(events.last),
|
|
66
|
+
events_summary: events.map { |e| { type: e.class.name, operation: event_operation(e), timestamp: event_timestamp(e) } }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Helper methods to extract data from events (works with both Lyra::Event and RubyEventStore::Event)
|
|
71
|
+
def event_timestamp(event)
|
|
72
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
73
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def event_operation(event)
|
|
77
|
+
return event.operation if event.respond_to?(:operation)
|
|
78
|
+
op = event.data[:operation] || event.data["operation"]
|
|
79
|
+
op.is_a?(String) ? op.to_sym : op
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Calculate differences between CRUD and Event-sourced states
|
|
83
|
+
def calculate_differences
|
|
84
|
+
crud = crud_state
|
|
85
|
+
es = event_sourced_state
|
|
86
|
+
|
|
87
|
+
return { exists_mismatch: true } if crud[:exists] != es[:exists]
|
|
88
|
+
return { no_differences: true } if !crud[:exists] && !es[:exists]
|
|
89
|
+
|
|
90
|
+
crud_attrs = crud[:attributes] || {}
|
|
91
|
+
es_attrs = es[:state] || {}
|
|
92
|
+
|
|
93
|
+
differences = {}
|
|
94
|
+
|
|
95
|
+
# Build lookup tables with both string and symbol keys
|
|
96
|
+
crud_lookup = build_key_lookup(crud_attrs)
|
|
97
|
+
es_lookup = build_key_lookup(es_attrs)
|
|
98
|
+
|
|
99
|
+
# Compare union of all keys from both sources, excluding DB-managed timestamps
|
|
100
|
+
ignored_keys = %i[created_at updated_at]
|
|
101
|
+
all_keys = (crud_lookup.keys + es_lookup.keys).uniq - ignored_keys
|
|
102
|
+
|
|
103
|
+
all_keys.each do |key|
|
|
104
|
+
crud_val = crud_lookup[key]
|
|
105
|
+
es_val = es_lookup[key]
|
|
106
|
+
|
|
107
|
+
unless values_equal?(crud_val, es_val)
|
|
108
|
+
differences[key] = {
|
|
109
|
+
crud: crud_val,
|
|
110
|
+
event_sourced: es_val
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
differences.empty? ? { no_differences: true } : differences
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get audit trail from events
|
|
119
|
+
def audit_trail
|
|
120
|
+
AuditProjection.audit_trail(model_class, model_id)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Build a normalized lookup table from attributes hash
|
|
126
|
+
# Converts all keys to symbols for consistent access
|
|
127
|
+
def build_key_lookup(attrs)
|
|
128
|
+
result = {}
|
|
129
|
+
attrs.each do |key, val|
|
|
130
|
+
result[key.to_sym] = val
|
|
131
|
+
end
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Compare values with type coercion for common mismatches
|
|
136
|
+
def values_equal?(crud_val, es_val)
|
|
137
|
+
return true if crud_val == es_val
|
|
138
|
+
return true if crud_val.nil? && es_val.nil?
|
|
139
|
+
return false if crud_val.nil? || es_val.nil?
|
|
140
|
+
|
|
141
|
+
# Normalize for comparison
|
|
142
|
+
normalized_crud = normalize_value(crud_val)
|
|
143
|
+
normalized_es = normalize_value(es_val)
|
|
144
|
+
|
|
145
|
+
normalized_crud == normalized_es
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalize_value(val)
|
|
149
|
+
case val
|
|
150
|
+
when Time, DateTime, ActiveSupport::TimeWithZone
|
|
151
|
+
val.utc.iso8601
|
|
152
|
+
when Date
|
|
153
|
+
val.iso8601
|
|
154
|
+
when BigDecimal
|
|
155
|
+
val.to_f.round(6)
|
|
156
|
+
when Float
|
|
157
|
+
val.round(6)
|
|
158
|
+
when Integer
|
|
159
|
+
val.to_f
|
|
160
|
+
when String
|
|
161
|
+
# Try to parse as time if it looks like a timestamp
|
|
162
|
+
if val =~ /^\d{4}-\d{2}-\d{2}(T|\s)/
|
|
163
|
+
begin
|
|
164
|
+
Time.parse(val).utc.iso8601
|
|
165
|
+
rescue
|
|
166
|
+
val
|
|
167
|
+
end
|
|
168
|
+
# Try to parse as number if it looks numeric
|
|
169
|
+
elsif val =~ /^-?\d+\.?\d*$/
|
|
170
|
+
val.to_f.round(6)
|
|
171
|
+
else
|
|
172
|
+
val
|
|
173
|
+
end
|
|
174
|
+
when TrueClass, FalseClass
|
|
175
|
+
val
|
|
176
|
+
when NilClass
|
|
177
|
+
nil
|
|
178
|
+
else
|
|
179
|
+
val.to_s
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Class methods for batch comparison
|
|
184
|
+
class << self
|
|
185
|
+
def compare_all(model_class)
|
|
186
|
+
model_class.find_each.map do |record|
|
|
187
|
+
new(model_class, record.id).compare
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def find_discrepancies(model_class)
|
|
192
|
+
compare_all(model_class).select do |comparison|
|
|
193
|
+
comparison[:differences] != { no_differences: true }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Analysis tools
|
|
200
|
+
class StateAnalyzer
|
|
201
|
+
def self.analyze(model_class, model_id)
|
|
202
|
+
view = DualView.new(model_class, model_id)
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
comparison: view.compare,
|
|
206
|
+
audit_trail: view.audit_trail,
|
|
207
|
+
recommendations: generate_recommendations(view)
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.generate_recommendations(view)
|
|
212
|
+
comparison = view.compare
|
|
213
|
+
recommendations = []
|
|
214
|
+
|
|
215
|
+
if comparison[:differences][:exists_mismatch]
|
|
216
|
+
recommendations << "State existence mismatch - investigate data consistency"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
if comparison[:differences] != { no_differences: true } && !comparison[:differences][:exists_mismatch]
|
|
220
|
+
recommendations << "Attribute differences detected - consider which source is authoritative"
|
|
221
|
+
recommendations << "Differences: #{comparison[:differences].keys.join(', ')}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if comparison[:event_sourced_view][:events_count] == 0 && comparison[:crud_view][:exists]
|
|
225
|
+
recommendations << "CRUD record exists but no events found - may have been created before Lyra was enabled"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
recommendations
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/lyra/engine.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Only define Engine when Rails is available
|
|
3
|
+
if defined?(Rails)
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Lyra
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :rspec
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "lyra.configure_rails_initialization" do
|
|
12
|
+
# Initialize Event Store
|
|
13
|
+
Lyra.event_store ||= RailsEventStore::Client.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "lyra.inject_interceptors", after: :load_config_initializers do
|
|
17
|
+
ActiveSupport.on_load(:active_record) do
|
|
18
|
+
require "lyra/interceptors/crud_interceptor"
|
|
19
|
+
include Lyra::Interceptors::CrudInterceptor
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Install association interceptor for disabled projections mode
|
|
24
|
+
# Must run after active_record is loaded
|
|
25
|
+
initializer "lyra.install_association_interceptor", after: "lyra.inject_interceptors" do
|
|
26
|
+
ActiveSupport.on_load(:active_record) do
|
|
27
|
+
require "lyra/interceptors/association_interceptor"
|
|
28
|
+
Lyra::Interceptors::AssociationInterceptor.install!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Install strict data access relation extension for update_all/delete_all
|
|
33
|
+
# This intercepts bulk operations on relations (Model.where(...).update_all)
|
|
34
|
+
initializer "lyra.install_strict_data_access", after: "lyra.inject_interceptors" do
|
|
35
|
+
ActiveSupport.on_load(:active_record) do
|
|
36
|
+
ActiveRecord::Relation.prepend(Lyra::StrictDataAccessRelation)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register event classes at startup for RailsEventStore deserialization
|
|
41
|
+
# This ensures Object.const_get() works when reading events from the store
|
|
42
|
+
initializer "lyra.register_event_classes", after: :load_config_initializers do
|
|
43
|
+
config.after_initialize do
|
|
44
|
+
next unless Lyra.config.monitored_models.any?
|
|
45
|
+
|
|
46
|
+
Lyra::Schema::EventClassRegistrar.register_all
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Schema validation on startup (after event classes are registered)
|
|
51
|
+
initializer "lyra.verify_schema", after: "lyra.register_event_classes" do
|
|
52
|
+
config.after_initialize do
|
|
53
|
+
next unless Lyra.config.monitored_models.any?
|
|
54
|
+
next unless Lyra.config.strict_schema
|
|
55
|
+
|
|
56
|
+
validator = Lyra::Schema::Validator.new
|
|
57
|
+
validator.enforce! if validator.schema_exists?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Load rake tasks
|
|
62
|
+
rake_tasks do
|
|
63
|
+
load "tasks/lyra_schema.rake"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/lyra/event.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Base event class for all Lyra events
|
|
3
|
+
class Event < RailsEventStore::Event
|
|
4
|
+
def self.inherited(subclass)
|
|
5
|
+
super
|
|
6
|
+
# Auto-register event types
|
|
7
|
+
Lyra::EventRegistry.register(subclass)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Accessor methods that handle both symbol and string keys
|
|
11
|
+
# (JSON serializer converts symbols to strings)
|
|
12
|
+
def model_class
|
|
13
|
+
data[:model_class] || data["model_class"]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model_id
|
|
17
|
+
data[:model_id] || data["model_id"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def operation
|
|
21
|
+
op = data[:operation] || data["operation"]
|
|
22
|
+
op.is_a?(String) ? op.to_sym : op
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def attributes
|
|
26
|
+
data[:attributes] || data["attributes"] || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def changes
|
|
30
|
+
data[:changes] || data["changes"] || {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def timestamp
|
|
34
|
+
data[:timestamp] || data["timestamp"] || metadata[:timestamp]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def user_id
|
|
38
|
+
# user_id is stored in data[:metadata] (nested), not top-level metadata
|
|
39
|
+
nested = data[:metadata] || data["metadata"] || {}
|
|
40
|
+
nested[:user_id] || nested["user_id"] || metadata[:user_id]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request_id
|
|
44
|
+
# request_id is stored in data[:metadata] (nested), not top-level metadata
|
|
45
|
+
nested = data[:metadata] || data["metadata"] || {}
|
|
46
|
+
nested[:request_id] || nested["request_id"] || metadata[:request_id]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module Events
|
|
51
|
+
# Dynamically created events will be placed here
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class EventRegistry
|
|
55
|
+
@events = []
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
def register(event_class)
|
|
59
|
+
@events << event_class unless @events.include?(event_class)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def all
|
|
63
|
+
@events
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def find_by_name(name)
|
|
67
|
+
@events.find { |e| e.name == name }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|