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