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,131 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Base aggregate class for event sourcing
|
|
3
|
+
class Aggregate
|
|
4
|
+
attr_reader :id, :version, :changes
|
|
5
|
+
|
|
6
|
+
def initialize(id = nil)
|
|
7
|
+
@id = id
|
|
8
|
+
@version = 0
|
|
9
|
+
@changes = []
|
|
10
|
+
@state = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Load aggregate from event stream
|
|
14
|
+
def self.load(id, event_store = nil)
|
|
15
|
+
event_store ||= Lyra.config.event_store
|
|
16
|
+
aggregate = new(id)
|
|
17
|
+
|
|
18
|
+
stream_name = aggregate.stream_name
|
|
19
|
+
events = event_store.read.stream(stream_name).to_a
|
|
20
|
+
|
|
21
|
+
events.each { |event| aggregate.apply(event, persisted: true) }
|
|
22
|
+
aggregate
|
|
23
|
+
rescue RailsEventStore::EventNotFound
|
|
24
|
+
new(id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Apply an event to the aggregate
|
|
28
|
+
def apply(event, persisted: false)
|
|
29
|
+
method_name = "apply_#{event.class.name.demodulize.underscore}"
|
|
30
|
+
|
|
31
|
+
if respond_to?(method_name, true)
|
|
32
|
+
send(method_name, event)
|
|
33
|
+
@version += 1 if persisted
|
|
34
|
+
@changes << event unless persisted
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Store pending changes to event store
|
|
39
|
+
def store(event_store = nil)
|
|
40
|
+
return if @changes.empty?
|
|
41
|
+
|
|
42
|
+
event_store ||= Lyra.config.event_store
|
|
43
|
+
|
|
44
|
+
@changes.each do |event|
|
|
45
|
+
event_store.publish(event, stream_name: stream_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@changes.clear
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stream_name
|
|
52
|
+
"#{self.class.name.demodulize}$#{id}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
attr_reader :state
|
|
58
|
+
|
|
59
|
+
def set_state(key, value)
|
|
60
|
+
@state[key] = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_state(key)
|
|
64
|
+
@state[key]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generic aggregate for monitored models
|
|
69
|
+
class GenericAggregate < Aggregate
|
|
70
|
+
def initialize(id = nil, model_class = nil)
|
|
71
|
+
super(id)
|
|
72
|
+
@model_class = model_class
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_name
|
|
76
|
+
"#{@model_class.name}$#{id}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Override apply to handle dynamic event class names (e.g., RegistrationCreated -> apply_created)
|
|
80
|
+
def apply(event, persisted: false)
|
|
81
|
+
event_name = event.class.name.demodulize.underscore
|
|
82
|
+
|
|
83
|
+
# Extract operation from event name (e.g., "registration_created" -> "created")
|
|
84
|
+
operation = extract_operation(event_name)
|
|
85
|
+
method_name = "apply_#{operation}"
|
|
86
|
+
|
|
87
|
+
if respond_to?(method_name, true)
|
|
88
|
+
send(method_name, event)
|
|
89
|
+
@version += 1 if persisted
|
|
90
|
+
@changes << event unless persisted
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def extract_operation(event_name)
|
|
97
|
+
# Match common operation suffixes
|
|
98
|
+
case event_name
|
|
99
|
+
when /_created$/
|
|
100
|
+
"created"
|
|
101
|
+
when /_updated$/
|
|
102
|
+
"updated"
|
|
103
|
+
when /_destroyed$/, /_deleted$/
|
|
104
|
+
"destroyed"
|
|
105
|
+
else
|
|
106
|
+
event_name
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply_created(event)
|
|
111
|
+
@id = event.data[:model_id] rescue event.model_id
|
|
112
|
+
attrs = event.data[:attributes] rescue event.attributes
|
|
113
|
+
attrs&.each { |k, v| set_state(k, v) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply_updated(event)
|
|
117
|
+
changes = event.data[:changes] rescue event.changes
|
|
118
|
+
changes&.each do |key, value|
|
|
119
|
+
# Handle both [old, new] arrays and direct values
|
|
120
|
+
new_val = value.is_a?(Array) ? value.last : value
|
|
121
|
+
set_state(key, new_val)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def apply_destroyed(event)
|
|
126
|
+
set_state(:deleted, true)
|
|
127
|
+
timestamp = event.data[:timestamp] rescue event.timestamp rescue Time.current
|
|
128
|
+
set_state(:deleted_at, timestamp)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
module Associations
|
|
5
|
+
# Event-aware association module for handling eventual consistency.
|
|
6
|
+
#
|
|
7
|
+
# In event_sourcing mode, associated records may not exist in the database
|
|
8
|
+
# yet if projections are async. This module provides event-aware versions
|
|
9
|
+
# of Rails associations that fall back to event reconstruction when needed.
|
|
10
|
+
#
|
|
11
|
+
# Usage in models:
|
|
12
|
+
# class Registration < ApplicationRecord
|
|
13
|
+
# include Lyra::Associations::EventAware
|
|
14
|
+
#
|
|
15
|
+
# event_aware_belongs_to :program
|
|
16
|
+
# event_aware_has_many :payment_transactions
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
module EventAware
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
class_methods do
|
|
23
|
+
# Event-aware belongs_to association
|
|
24
|
+
#
|
|
25
|
+
# Tries database first, falls back to event reconstruction if not found.
|
|
26
|
+
#
|
|
27
|
+
# @param name [Symbol] Association name
|
|
28
|
+
# @param options [Hash] Standard belongs_to options plus:
|
|
29
|
+
# - class_name: Override the class name
|
|
30
|
+
# - foreign_key: Override the foreign key
|
|
31
|
+
def event_aware_belongs_to(name, **options)
|
|
32
|
+
foreign_key = options[:foreign_key] || "#{name}_id"
|
|
33
|
+
class_name = (options[:class_name] || name.to_s.camelize).to_s
|
|
34
|
+
|
|
35
|
+
define_method(name) do
|
|
36
|
+
fk_value = send(foreign_key)
|
|
37
|
+
return nil if fk_value.nil?
|
|
38
|
+
|
|
39
|
+
# Try database first (fast path)
|
|
40
|
+
associated = class_name.constantize.find_by(id: fk_value)
|
|
41
|
+
return associated if associated
|
|
42
|
+
|
|
43
|
+
# Fall back to event store reconstruction (slow path)
|
|
44
|
+
return nil unless Lyra.event_sourcing_mode?
|
|
45
|
+
|
|
46
|
+
EventReconstructor.reconstruct(class_name.constantize, fk_value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
define_method("#{name}=") do |value|
|
|
50
|
+
send("#{foreign_key}=", value&.id)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Event-aware has_many association
|
|
55
|
+
#
|
|
56
|
+
# Queries database and optionally includes pending records from events.
|
|
57
|
+
#
|
|
58
|
+
# @param name [Symbol] Association name (plural)
|
|
59
|
+
# @param options [Hash] Options including:
|
|
60
|
+
# - class_name: Override the class name
|
|
61
|
+
# - foreign_key: Override the foreign key
|
|
62
|
+
def event_aware_has_many(name, **options)
|
|
63
|
+
foreign_key = options[:foreign_key] || "#{model_name.singular}_id"
|
|
64
|
+
class_name = (options[:class_name] || name.to_s.singularize.camelize).to_s
|
|
65
|
+
|
|
66
|
+
define_method(name) do
|
|
67
|
+
model_id = id
|
|
68
|
+
return [] unless model_id
|
|
69
|
+
|
|
70
|
+
# Query database
|
|
71
|
+
db_records = class_name.constantize.where(foreign_key => model_id)
|
|
72
|
+
|
|
73
|
+
# In event_sourcing mode with async projections, include pending records
|
|
74
|
+
if Lyra.event_sourcing_mode? && Lyra.config.projection_mode == :async
|
|
75
|
+
pending = PendingRecords.for(class_name.constantize, foreign_key, model_id)
|
|
76
|
+
(db_records.to_a + pending).uniq(&:id)
|
|
77
|
+
else
|
|
78
|
+
db_records
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reconstructs a record from its event stream
|
|
86
|
+
class EventReconstructor
|
|
87
|
+
class << self
|
|
88
|
+
# Reconstruct a record's current state from events
|
|
89
|
+
#
|
|
90
|
+
# @param model_class [Class] The model class
|
|
91
|
+
# @param id [Integer, String] The record ID
|
|
92
|
+
# @return [VirtualRecord, nil] A virtual record or nil if not found/deleted
|
|
93
|
+
def reconstruct(model_class, id)
|
|
94
|
+
stream_name = "#{model_class.name}$#{id}"
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
98
|
+
rescue StandardError
|
|
99
|
+
return nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return nil if events.empty?
|
|
103
|
+
|
|
104
|
+
# Replay events to build current state
|
|
105
|
+
state = replay_events(events)
|
|
106
|
+
|
|
107
|
+
return nil if state[:deleted]
|
|
108
|
+
|
|
109
|
+
VirtualRecord.new(model_class, id, state)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def replay_events(events)
|
|
115
|
+
state = { deleted: false }
|
|
116
|
+
|
|
117
|
+
events.each do |event|
|
|
118
|
+
data = event.data.is_a?(Hash) ? event.data : {}
|
|
119
|
+
operation = data[:operation] || data["operation"]
|
|
120
|
+
|
|
121
|
+
case operation&.to_sym
|
|
122
|
+
when :created
|
|
123
|
+
state.merge!(data[:attributes] || data["attributes"] || {})
|
|
124
|
+
when :updated
|
|
125
|
+
changes = data[:changes] || data["changes"] || {}
|
|
126
|
+
changes.each do |field, change|
|
|
127
|
+
new_value = change.is_a?(Array) ? change.last : change
|
|
128
|
+
state[field.to_sym] = new_value
|
|
129
|
+
end
|
|
130
|
+
when :destroyed
|
|
131
|
+
state[:deleted] = true
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
state
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Finds records that exist in events but not yet in the database
|
|
141
|
+
class PendingRecords
|
|
142
|
+
class << self
|
|
143
|
+
# Find pending records for a has_many association
|
|
144
|
+
#
|
|
145
|
+
# @param model_class [Class] The associated model class
|
|
146
|
+
# @param foreign_key [String] The foreign key field
|
|
147
|
+
# @param parent_id [Integer, String] The parent record ID
|
|
148
|
+
# @return [Array<VirtualRecord>] Array of virtual records
|
|
149
|
+
def for(model_class, foreign_key, parent_id)
|
|
150
|
+
# This is a simplified implementation
|
|
151
|
+
# A production version would need to track pending events more efficiently
|
|
152
|
+
[]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Virtual record representing a record from events (not in DB)
|
|
158
|
+
#
|
|
159
|
+
# Provides a read-only interface that looks like an ActiveRecord model
|
|
160
|
+
# but is backed by event data instead of a database row.
|
|
161
|
+
class VirtualRecord
|
|
162
|
+
attr_reader :id
|
|
163
|
+
|
|
164
|
+
def initialize(model_class, id, state)
|
|
165
|
+
@model_class = model_class
|
|
166
|
+
@id = id
|
|
167
|
+
@state = state.transform_keys(&:to_sym)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def persisted?
|
|
171
|
+
false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def new_record?
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Indicates this is a virtual record from events
|
|
179
|
+
def pending_projection?
|
|
180
|
+
true
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def readonly?
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Access attributes
|
|
188
|
+
def [](key)
|
|
189
|
+
@state[key.to_sym]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def attributes
|
|
193
|
+
@state.except(:deleted)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_param
|
|
197
|
+
id&.to_s
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def method_missing(method, *args)
|
|
201
|
+
method_name = method.to_s
|
|
202
|
+
|
|
203
|
+
# Getter
|
|
204
|
+
if @state.key?(method)
|
|
205
|
+
@state[method]
|
|
206
|
+
# Boolean query
|
|
207
|
+
elsif method_name.end_with?("?")
|
|
208
|
+
field = method_name.chomp("?").to_sym
|
|
209
|
+
!!@state[field]
|
|
210
|
+
# Setter (rejected - read only)
|
|
211
|
+
elsif method_name.end_with?("=")
|
|
212
|
+
raise ReadOnlyRecord, "Cannot modify a virtual record"
|
|
213
|
+
else
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def respond_to_missing?(method, include_private = false)
|
|
219
|
+
@state.key?(method) || method.to_s.end_with?("?") || super
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
class ReadOnlyRecord < StandardError; end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
data/lib/lyra/command.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Base command class
|
|
3
|
+
class Command
|
|
4
|
+
attr_reader :model_class, :data
|
|
5
|
+
|
|
6
|
+
def initialize(model_class, data = {})
|
|
7
|
+
@model_class = model_class
|
|
8
|
+
@data = data
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def aggregate_id
|
|
12
|
+
data[:id] || data['id']
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Commands
|
|
17
|
+
class CreateCommand < Command
|
|
18
|
+
def initialize(model_class, attributes)
|
|
19
|
+
super(model_class, attributes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def attributes
|
|
23
|
+
data
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class UpdateCommand < Command
|
|
28
|
+
def initialize(model_class, id, changes)
|
|
29
|
+
super(model_class, id: id, changes: changes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def id
|
|
33
|
+
data[:id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def changes
|
|
37
|
+
data[:changes]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class DestroyCommand < Command
|
|
42
|
+
def initialize(model_class, id)
|
|
43
|
+
super(model_class, id: id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def id
|
|
47
|
+
data[:id]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Command result
|
|
53
|
+
class CommandResult
|
|
54
|
+
attr_reader :success, :attributes, :error, :events
|
|
55
|
+
|
|
56
|
+
def initialize(success:, attributes: {}, error: nil, events: [])
|
|
57
|
+
@success = success
|
|
58
|
+
@attributes = attributes
|
|
59
|
+
@error = error
|
|
60
|
+
@events = events
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def success?
|
|
64
|
+
@success
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def failure?
|
|
68
|
+
!success?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
def success(attributes: {}, events: [])
|
|
73
|
+
new(success: true, attributes: attributes, events: events)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def failure(error:)
|
|
77
|
+
new(success: false, error: error)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Command handler for processing commands in hijack mode
|
|
3
|
+
class CommandHandler
|
|
4
|
+
class << self
|
|
5
|
+
def handle(command)
|
|
6
|
+
handler = new(command)
|
|
7
|
+
handler.call
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :command
|
|
12
|
+
|
|
13
|
+
def initialize(command)
|
|
14
|
+
@command = command
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
case command
|
|
19
|
+
when Commands::CreateCommand
|
|
20
|
+
handle_create
|
|
21
|
+
when Commands::UpdateCommand
|
|
22
|
+
handle_update
|
|
23
|
+
when Commands::DestroyCommand
|
|
24
|
+
handle_destroy
|
|
25
|
+
else
|
|
26
|
+
CommandResult.failure(error: "Unknown command type")
|
|
27
|
+
end
|
|
28
|
+
rescue => e
|
|
29
|
+
CommandResult.failure(error: e.message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def handle_create
|
|
35
|
+
model_class = command.model_class
|
|
36
|
+
|
|
37
|
+
# Generate ID for new aggregate
|
|
38
|
+
# In event_sourcing mode, we MUST pre-generate IDs since we abort the DB save
|
|
39
|
+
# In hijack mode, only UUID keys need pre-generation (integers come from DB)
|
|
40
|
+
id = if Lyra.event_sourcing_mode?
|
|
41
|
+
# Always pre-generate in event_sourcing mode
|
|
42
|
+
IdGenerator.next_id(model_class)
|
|
43
|
+
elsif model_class.columns_hash[model_class.primary_key]&.type == :uuid
|
|
44
|
+
SecureRandom.uuid
|
|
45
|
+
else
|
|
46
|
+
# For integer primary keys in hijack mode, use a temporary placeholder
|
|
47
|
+
# The actual ID will be assigned by the database after save
|
|
48
|
+
"pending-#{SecureRandom.hex(8)}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Create aggregate
|
|
52
|
+
aggregate_class = find_aggregate_class
|
|
53
|
+
aggregate = aggregate_class.new(id, command.model_class)
|
|
54
|
+
|
|
55
|
+
# Create event with pre-generated ID
|
|
56
|
+
# Use symbolize_keys for consistent key types in event data
|
|
57
|
+
event_attrs = command.attributes.symbolize_keys.merge(id: id)
|
|
58
|
+
event = create_event(:created, id, event_attrs)
|
|
59
|
+
|
|
60
|
+
# Apply event to aggregate
|
|
61
|
+
aggregate.apply(event)
|
|
62
|
+
|
|
63
|
+
# Store events
|
|
64
|
+
# In event_sourcing mode, defer storage until after throw(:abort) completes
|
|
65
|
+
# (storing inside the callback would be rolled back with the transaction)
|
|
66
|
+
unless Lyra.event_sourcing_mode?
|
|
67
|
+
aggregate.store(Lyra.config.event_store)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Return result with ID
|
|
71
|
+
# In event_sourcing mode, always include ID (it's pre-generated)
|
|
72
|
+
# In hijack mode, only include for UUID (integers come from DB)
|
|
73
|
+
attributes = command.attributes.dup
|
|
74
|
+
# Remove string "id" key to prevent conflict with symbol :id
|
|
75
|
+
# (AR attributes have string keys, but we add symbol keys)
|
|
76
|
+
attributes.delete("id")
|
|
77
|
+
if Lyra.event_sourcing_mode? || model_class.columns_hash[model_class.primary_key]&.type == :uuid
|
|
78
|
+
attributes[:id] = id
|
|
79
|
+
end
|
|
80
|
+
CommandResult.success(attributes: attributes, events: [event])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_update
|
|
84
|
+
# Load aggregate (or create new one for records without event history)
|
|
85
|
+
aggregate_class = find_aggregate_class
|
|
86
|
+
aggregate = aggregate_class.load(command.id, Lyra.config.event_store) rescue aggregate_class.new(command.id, command.model_class)
|
|
87
|
+
|
|
88
|
+
# Create event
|
|
89
|
+
event = create_event(:updated, command.id, { changes: command.changes })
|
|
90
|
+
|
|
91
|
+
# Apply event to aggregate
|
|
92
|
+
aggregate.apply(event)
|
|
93
|
+
|
|
94
|
+
# Store events (defer in event_sourcing mode)
|
|
95
|
+
unless Lyra.event_sourcing_mode?
|
|
96
|
+
aggregate.store(Lyra.config.event_store)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
CommandResult.success(events: [event])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_destroy
|
|
103
|
+
# Load aggregate (or create new one for records without event history)
|
|
104
|
+
aggregate_class = find_aggregate_class
|
|
105
|
+
aggregate = aggregate_class.load(command.id, Lyra.config.event_store) rescue aggregate_class.new(command.id, command.model_class)
|
|
106
|
+
|
|
107
|
+
# Create event
|
|
108
|
+
event = create_event(:destroyed, command.id, {})
|
|
109
|
+
|
|
110
|
+
# Apply event to aggregate
|
|
111
|
+
aggregate.apply(event)
|
|
112
|
+
|
|
113
|
+
# Store events (defer in event_sourcing mode)
|
|
114
|
+
unless Lyra.event_sourcing_mode?
|
|
115
|
+
aggregate.store(Lyra.config.event_store)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
CommandResult.success(events: [event])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def create_event(operation, id, data)
|
|
122
|
+
event_data = {
|
|
123
|
+
model_class: command.model_class.name,
|
|
124
|
+
model_id: id,
|
|
125
|
+
operation: operation,
|
|
126
|
+
attributes: data[:attributes] || data,
|
|
127
|
+
changes: data[:changes] || {},
|
|
128
|
+
timestamp: Time.current
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
event_metadata = {
|
|
132
|
+
source: 'lyra_command_handler',
|
|
133
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
134
|
+
causation_id: Lyra::Causation.current_id
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
config = Lyra.config.model_config(command.model_class)
|
|
138
|
+
event_name = config.event_name_for(operation)
|
|
139
|
+
|
|
140
|
+
# Find or create the event class in Lyra::Events namespace
|
|
141
|
+
event_class = if Lyra::Events.const_defined?(event_name, false)
|
|
142
|
+
Lyra::Events.const_get(event_name, false)
|
|
143
|
+
else
|
|
144
|
+
Lyra::Events.const_set(event_name, Class.new(Lyra::Event))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
event_class.new(data: event_data, metadata: event_metadata)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def find_aggregate_class
|
|
151
|
+
config = Lyra.config.model_config(command.model_class)
|
|
152
|
+
config.aggregate_class || Lyra::GenericAggregate
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
class Configuration
|
|
3
|
+
# Valid modes for Lyra operation
|
|
4
|
+
MODES = [:disabled, :monitor, :hijack, :event_sourcing].freeze
|
|
5
|
+
|
|
6
|
+
attr_accessor :mode, :event_store, :event_backend, :hijack_enabled, :retention_policy
|
|
7
|
+
attr_accessor :projection_mode, :strict_projections, :projection_error_handler, :async_projections_inline
|
|
8
|
+
attr_accessor :strict_schema, :schema_path
|
|
9
|
+
attr_accessor :strict_data_access # Raise on callback-bypassing operations
|
|
10
|
+
attr_accessor :metadata_proc # Custom metadata proc for events
|
|
11
|
+
attr_reader :monitored_models
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@mode = :monitor
|
|
15
|
+
@event_backend = :rails_event_store
|
|
16
|
+
@hijack_enabled = false
|
|
17
|
+
@monitored_models = []
|
|
18
|
+
@model_configs = {}
|
|
19
|
+
@retention_policy = nil
|
|
20
|
+
# Event sourcing specific options
|
|
21
|
+
@projection_mode = :sync # :sync, :async, or :disabled
|
|
22
|
+
@strict_projections = false # Raise on projection errors if true
|
|
23
|
+
@projection_error_handler = nil # Custom error handler proc
|
|
24
|
+
@async_projections_inline = false # Run async projections synchronously (useful for testing)
|
|
25
|
+
# Schema validation options
|
|
26
|
+
@strict_schema = false # Fail on startup if schema changes detected
|
|
27
|
+
@schema_path = nil # Custom path for schema files (defaults to db/lyra_schemas/)
|
|
28
|
+
# Strict data access - raise on operations that bypass callbacks
|
|
29
|
+
@strict_data_access = false
|
|
30
|
+
# User tracking - custom metadata proc called for every event
|
|
31
|
+
# Signature: ->(record, operation) { { user_id: ..., ... } }
|
|
32
|
+
@metadata_proc = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Register a model for monitoring/hijacking
|
|
36
|
+
def monitor_model(model_class, options = {})
|
|
37
|
+
# Check by name to handle Rails development reloading (class objects change on reload)
|
|
38
|
+
model_name = begin
|
|
39
|
+
model_class.name
|
|
40
|
+
rescue StandardError
|
|
41
|
+
model_class.object_id.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless @monitored_models.any? { |m| (m.name rescue m.object_id.to_s) == model_name }
|
|
45
|
+
@monitored_models << model_class
|
|
46
|
+
else
|
|
47
|
+
# Update the reference to the new class object (after reload)
|
|
48
|
+
@monitored_models.map! { |m| (m.name rescue m.object_id.to_s) == model_name ? model_class : m }
|
|
49
|
+
end
|
|
50
|
+
@model_configs[model_class] = ModelConfiguration.new(model_class, options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def model_config(model_class)
|
|
54
|
+
@model_configs[model_class] || ModelConfiguration.new(model_class)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def monitor_mode?
|
|
58
|
+
@mode == :monitor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hijack_mode?
|
|
62
|
+
@mode == :hijack || @hijack_enabled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def event_sourcing_mode?
|
|
66
|
+
@mode == :event_sourcing
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def disabled_mode?
|
|
70
|
+
@mode == :disabled
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Enable hijack mode (can override CRUD operations)
|
|
74
|
+
def enable_hijack!
|
|
75
|
+
@hijack_enabled = true
|
|
76
|
+
@mode = :hijack
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Enable monitor mode (only log events, don't override)
|
|
80
|
+
def enable_monitor!
|
|
81
|
+
@hijack_enabled = false
|
|
82
|
+
@mode = :monitor
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Enable event sourcing mode (events as source of truth, no direct DB writes)
|
|
86
|
+
def enable_event_sourcing!
|
|
87
|
+
@mode = :event_sourcing
|
|
88
|
+
@hijack_enabled = false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Disable Lyra completely
|
|
92
|
+
def disable!
|
|
93
|
+
@mode = :disabled
|
|
94
|
+
@hijack_enabled = false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class ModelConfiguration
|
|
99
|
+
attr_accessor :event_prefix, :aggregate_class, :command_handler, :privacy_policy
|
|
100
|
+
attr_reader :model_class
|
|
101
|
+
|
|
102
|
+
def initialize(model_class, options = {})
|
|
103
|
+
@model_class = model_class
|
|
104
|
+
@event_prefix = options[:event_prefix] || model_class.name
|
|
105
|
+
@aggregate_class = options[:aggregate_class]
|
|
106
|
+
@command_handler = options[:command_handler]
|
|
107
|
+
@custom_event_mapping = options[:event_mapping] || {}
|
|
108
|
+
@privacy_policy = options[:privacy_policy]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def event_name_for(operation)
|
|
112
|
+
@custom_event_mapping[operation] || "#{event_prefix}#{operation.to_s.camelize}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.config
|
|
117
|
+
@config ||= Configuration.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Reset configuration (useful for testing)
|
|
121
|
+
def self.reset_config!
|
|
122
|
+
@config = Configuration.new
|
|
123
|
+
end
|
|
124
|
+
end
|