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,85 @@
1
+ module Lyra
2
+ module Privacy
3
+ # Detects personally identifiable information (PII) in data
4
+ #
5
+ # This class delegates to PamDsl::PIIDetector for the core PII detection logic,
6
+ # providing a unified implementation across both Lyra and PAM DSL.
7
+ #
8
+ # @example Detect PII in attributes
9
+ # PIIDetector.detect({ email: "test@example.com", name: "John" })
10
+ # # => { email: { type: :email, value: "test@example.com", sensitive: false }, ... }
11
+ #
12
+ # @example Check if a field contains PII
13
+ # PIIDetector.contains_pii?(:email) # => true
14
+ # PIIDetector.contains_pii?(:count) # => false
15
+ #
16
+ class PIIDetector
17
+ class << self
18
+ # Detect PII in attributes
19
+ #
20
+ # @param attributes [Hash] Key-value pairs to check for PII
21
+ # @return [Hash] Keys that contain PII, with their type, value, and sensitivity
22
+ #
23
+ def detect(attributes)
24
+ PamDsl::PIIDetector.detect(attributes)
25
+ end
26
+
27
+ # Check if a field contains PII
28
+ #
29
+ # @param field_name [String, Symbol] Field name to check
30
+ # @return [Boolean] true if field contains PII
31
+ #
32
+ def contains_pii?(field_name)
33
+ PamDsl::PIIDetector.contains_pii?(field_name)
34
+ end
35
+
36
+ # Mask PII for display
37
+ #
38
+ # @param value [Object] The value to mask
39
+ # @param pii_type [Symbol] The type of PII
40
+ # @return [String] Masked value
41
+ #
42
+ def mask(value, pii_type)
43
+ PamDsl::PIIDetector.mask(value, pii_type)
44
+ end
45
+
46
+ # Extract all PII from event stream
47
+ #
48
+ # This is a convenience wrapper around PamDsl::PIIDetector.extract_pii_from_records
49
+ # that provides Lyra-specific extractors for Lyra::Event objects.
50
+ #
51
+ # @param events [Array<Lyra::Event>] Events to scan for PII
52
+ # @return [Hash] PII inventory grouped by type
53
+ #
54
+ # @example
55
+ # events = Lyra.config.event_store.read.stream("User$123").to_a
56
+ # pii = PIIDetector.extract_from_event_stream(events)
57
+ # # => { email: [{ field: :email, value: "...", event_id: "...", ... }], ... }
58
+ #
59
+ def extract_from_event_stream(events)
60
+ PamDsl::PIIDetector.extract_pii_from_records(
61
+ events,
62
+ attribute_extractor: ->(e) { e.attributes },
63
+ metadata_extractor: ->(e) {
64
+ {
65
+ event_id: e.event_id,
66
+ timestamp: e.timestamp,
67
+ model_class: e.model_class,
68
+ model_id: e.model_id
69
+ }
70
+ }
71
+ )
72
+ end
73
+
74
+ # Check if a PII type is considered sensitive
75
+ #
76
+ # @param pii_type [Symbol] The PII type
77
+ # @return [Boolean]
78
+ #
79
+ def sensitive?(pii_type)
80
+ PamDsl::PIIDetector.sensitive?(pii_type)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,66 @@
1
+ module Lyra
2
+ module Privacy
3
+ # Masks PII in attribute hashes
4
+ #
5
+ # This class delegates to PamDsl::PIIMasker for the core masking logic,
6
+ # providing a unified implementation across both Lyra and PAM DSL.
7
+ #
8
+ # @example Mask all PII in attributes
9
+ # PIIMasker.mask({ email: "test@example.com", name: "John" })
10
+ # # => { email: "t***@example.com", name: "John ***" }
11
+ #
12
+ # @example Full redaction
13
+ # PIIMasker.mask({ email: "test@example.com" }, strategy: :full)
14
+ # # => { email: "[REDACTED]" }
15
+ #
16
+ class PIIMasker
17
+ class << self
18
+ # Mask all PII fields in an attributes hash
19
+ #
20
+ # @param attributes [Hash] Key-value pairs to mask
21
+ # @param strategy [Symbol] Masking strategy (:partial, :full, :redact_sensitive)
22
+ # @return [Hash] New hash with PII fields masked
23
+ #
24
+ def mask(attributes, strategy: :partial)
25
+ PamDsl::PIIMasker.mask(attributes, strategy: strategy)
26
+ end
27
+
28
+ # Mask specific field
29
+ #
30
+ # @param value [Object] The value to mask
31
+ # @param field_name [String, Symbol] Field name to determine PII type
32
+ # @return [Object] Masked value, or original if not PII
33
+ #
34
+ def mask_field(value, field_name)
35
+ PamDsl::PIIMasker.mask_field(value, field_name)
36
+ end
37
+
38
+ # Mask all PII in a collection of Lyra events
39
+ #
40
+ # @param events [Array<Lyra::Event>] Events to mask
41
+ # @param strategy [Symbol] Masking strategy
42
+ # @return [Array] Events with masked attributes
43
+ #
44
+ def mask_events(events, strategy: :partial)
45
+ PamDsl::PIIMasker.mask_records(
46
+ events,
47
+ attribute_extractor: ->(e) { e.attributes },
48
+ attribute_setter: ->(e, masked) {
49
+ Lyra::Event.new(
50
+ event_id: e.event_id,
51
+ operation: e.operation,
52
+ model_class: e.model_class,
53
+ model_id: e.model_id,
54
+ attributes: masked,
55
+ changes: e.changes,
56
+ timestamp: e.timestamp,
57
+ metadata: e.metadata
58
+ )
59
+ },
60
+ strategy: strategy
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,253 @@
1
+ module Lyra
2
+ module Privacy
3
+ # Integration layer between Lyra and PAM DSL
4
+ #
5
+ # When PAM DSL is available, combines policy-defined fields with
6
+ # pattern-based PIIDetector for comprehensive PII detection.
7
+ #
8
+ # @example With policy and detector fallback (default)
9
+ # integration = PolicyIntegration.new(:my_policy)
10
+ # integration.detect_pii(attrs) # Uses policy + detector
11
+ #
12
+ # @example Policy-only mode
13
+ # integration = PolicyIntegration.new(:my_policy, use_detector: false)
14
+ # integration.detect_pii(attrs) # Only fields defined in policy
15
+ #
16
+ class PolicyIntegration
17
+ attr_reader :policy, :use_detector
18
+
19
+ def initialize(policy_name, use_detector: true)
20
+ @use_detector = use_detector
21
+ @policy = PamDsl.policy(policy_name)
22
+ rescue PamDsl::PolicyNotFoundError
23
+ @policy = nil
24
+ rescue NameError
25
+ # PAM DSL not available
26
+ @policy = nil
27
+ @use_detector = false
28
+ end
29
+
30
+ # Check if PAM DSL is available
31
+ def pam_dsl_available?
32
+ defined?(PamDsl)
33
+ end
34
+
35
+ # Check if policy is loaded
36
+ def policy_loaded?
37
+ !@policy.nil?
38
+ end
39
+
40
+ # Validate data access for a purpose
41
+ def validate_access!(field_names, purpose, consent_status = {})
42
+ return true unless policy_loaded?
43
+
44
+ @policy.validate_access!(
45
+ field_names,
46
+ purpose,
47
+ consent_granted: consent_status[:granted] || false,
48
+ consent_granted_at: consent_status[:granted_at]
49
+ )
50
+ end
51
+
52
+ # Get PII fields from attributes using policy and/or detector
53
+ #
54
+ # Priority:
55
+ # 1. Fields defined in policy (explicit configuration)
56
+ # 2. Fields detected by PIIDetector (pattern-based, if use_detector: true)
57
+ #
58
+ # @param attributes [Hash] Key-value pairs to check for PII
59
+ # @return [Hash] PII fields with type, value, sensitivity
60
+ #
61
+ def detect_pii(attributes)
62
+ return {} unless pam_dsl_available?
63
+
64
+ pii_fields = {}
65
+
66
+ attributes.each do |key, value|
67
+ field_name = key.to_sym
68
+
69
+ # Try policy first
70
+ if policy_loaded?
71
+ begin
72
+ field = @policy.get_field(field_name)
73
+ pii_fields[key] = {
74
+ type: field.type,
75
+ value: value,
76
+ sensitive: field.sensitive?,
77
+ sensitivity: field.sensitivity,
78
+ source: :policy
79
+ }
80
+ next
81
+ rescue PamDsl::InvalidFieldError
82
+ # Field not in policy, try detector below
83
+ end
84
+ end
85
+
86
+ # Fall back to PIIDetector if enabled
87
+ if @use_detector
88
+ detected = PamDsl::PIIDetector.detect({ key => value })
89
+ if detected[key]
90
+ pii_fields[key] = detected[key].merge(source: :detector)
91
+ end
92
+ end
93
+ end
94
+
95
+ pii_fields
96
+ end
97
+
98
+ # Mask PII using policy transformations or PIIDetector
99
+ #
100
+ # @param field_name [Symbol, String] Field name
101
+ # @param value [Object] Value to mask
102
+ # @param context [Symbol] Transformation context (default: :display)
103
+ # @return [Object] Masked value, or original if no masking available
104
+ #
105
+ def mask_pii(field_name, value, context = :display)
106
+ return value unless pam_dsl_available?
107
+
108
+ # Try policy transformation first
109
+ if policy_loaded?
110
+ begin
111
+ field = @policy.get_field(field_name)
112
+ return field.apply_transformation(context, value)
113
+ rescue PamDsl::InvalidFieldError
114
+ # Field not in policy, try detector below
115
+ end
116
+ end
117
+
118
+ # Fall back to PIIDetector masking if enabled
119
+ if @use_detector
120
+ pii_type = PamDsl::PIIDetector.pii_type(field_name)
121
+ return PamDsl::PIIDetector.mask(value, pii_type) if pii_type
122
+ end
123
+
124
+ value
125
+ end
126
+
127
+ # Get retention duration for model and field
128
+ # Returns nil if no policy loaded (infinite/manual retention)
129
+ def retention_duration(model_class, field_name: nil)
130
+ return nil unless policy_loaded?
131
+ @policy.retention_for(model_class, field_name: field_name)
132
+ end
133
+
134
+ # Check if consent is required for purpose
135
+ def consent_required?(purpose)
136
+ return false unless policy_loaded?
137
+
138
+ begin
139
+ purpose_obj = @policy.get_purpose(purpose)
140
+ purpose_obj.requires_consent?
141
+ rescue PamDsl::Error
142
+ false
143
+ end
144
+ end
145
+
146
+ # Get allowed purposes for a field
147
+ def allowed_purposes(field_name)
148
+ return [] unless policy_loaded?
149
+
150
+ begin
151
+ field = @policy.get_field(field_name)
152
+ field.purposes
153
+ rescue PamDsl::InvalidFieldError
154
+ []
155
+ end
156
+ end
157
+
158
+ # Check if field is allowed for purpose
159
+ def allowed?(field_name, purpose)
160
+ return true unless policy_loaded?
161
+ @policy.allowed?(field_name, purpose)
162
+ end
163
+
164
+ # Get all sensitive fields
165
+ def sensitive_fields
166
+ return [] unless policy_loaded?
167
+ @policy.sensitive_fields.map(&:name)
168
+ end
169
+
170
+ # Get all restricted fields
171
+ def restricted_fields
172
+ return [] unless policy_loaded?
173
+ @policy.restricted_fields.map(&:name)
174
+ end
175
+
176
+ # Get policy metadata
177
+ def metadata
178
+ return {} unless policy_loaded?
179
+ @policy.metadata
180
+ end
181
+
182
+ # Export policy information
183
+ def to_h
184
+ {
185
+ pam_dsl_available: pam_dsl_available?,
186
+ policy_loaded: policy_loaded?,
187
+ use_detector: @use_detector
188
+ }.tap do |info|
189
+ if policy_loaded?
190
+ info.merge!(
191
+ policy_name: @policy.name,
192
+ fields_count: @policy.fields.count,
193
+ purposes_count: @policy.purposes.count,
194
+ sensitive_fields: sensitive_fields,
195
+ restricted_fields: restricted_fields,
196
+ metadata: @policy.metadata
197
+ )
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ # Extend GDPRCompliance with policy integration
204
+ class GDPRCompliance
205
+ # Use privacy policy for PII detection if available
206
+ #
207
+ # @param attributes [Hash] Attributes to check
208
+ # @param policy_name [Symbol] Policy name
209
+ # @param use_detector [Boolean] Fall back to PIIDetector (default: true)
210
+ #
211
+ def detect_pii_with_policy(attributes, policy_name, use_detector: true)
212
+ integration = PolicyIntegration.new(policy_name, use_detector: use_detector)
213
+ integration.detect_pii(attributes)
214
+ end
215
+
216
+ # Check retention compliance with policy
217
+ # Returns nil for models without defined retention (infinite/manual)
218
+ def retention_compliance_with_policy(policy_name)
219
+ integration = PolicyIntegration.new(policy_name)
220
+ events = collect_all_events
221
+
222
+ events.group_by { |e| e.model_class }.map do |model_class, model_events|
223
+ retention_duration = integration.retention_duration(model_class)
224
+
225
+ # nil means infinite/manual retention - always compliant
226
+ if retention_duration.nil?
227
+ {
228
+ model_class: model_class,
229
+ total_events: model_events.count,
230
+ expired_events: 0,
231
+ retention_period: nil,
232
+ compliance_status: :manual,
233
+ expired_event_ids: []
234
+ }
235
+ else
236
+ expired = model_events.select do |event|
237
+ event.timestamp && event.timestamp < (Time.current - retention_duration)
238
+ end
239
+
240
+ {
241
+ model_class: model_class,
242
+ total_events: model_events.count,
243
+ expired_events: expired.count,
244
+ retention_period: retention_duration,
245
+ compliance_status: expired.empty? ? :compliant : :requires_action,
246
+ expired_event_ids: expired.map(&:event_id)
247
+ }
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,94 @@
1
+ module Lyra
2
+ # Base class for projections (read models)
3
+ class Projection
4
+ class << self
5
+ def handle(event)
6
+ new.handle(event)
7
+ end
8
+
9
+ def subscribe_to(*event_types)
10
+ event_types.each do |event_type|
11
+ Lyra.config.event_store.subscribe(self, to: [event_type])
12
+ end
13
+ end
14
+ end
15
+
16
+ def handle(event)
17
+ method_name = "apply_#{event.class.name.demodulize.underscore}"
18
+ send(method_name, event) if respond_to?(method_name, true)
19
+ end
20
+ end
21
+
22
+ # State projection that rebuilds current state from events
23
+ class StateProjection < Projection
24
+ def self.rebuild_state(model_class, model_id)
25
+ stream_name = "#{model_class.name}$#{model_id}"
26
+ events = Lyra.config.event_store.read.stream(stream_name).to_a
27
+
28
+ new.rebuild_from_events(events)
29
+ end
30
+
31
+ def rebuild_from_events(events)
32
+ state = {}
33
+
34
+ events.each do |event|
35
+ case event_operation(event)
36
+ when :created
37
+ state = event_attributes(event)
38
+ when :updated
39
+ state.merge!(event_changes(event).transform_values { |v| v.last })
40
+ when :destroyed
41
+ state[:deleted] = true
42
+ state[:deleted_at] = event_timestamp(event)
43
+ end
44
+ end
45
+
46
+ state
47
+ end
48
+
49
+ private
50
+
51
+ def event_operation(event)
52
+ return event.operation if event.respond_to?(:operation)
53
+ op = event.data[:operation] || event.data["operation"]
54
+ op.is_a?(String) ? op.to_sym : op
55
+ end
56
+
57
+ def event_attributes(event)
58
+ return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Hash)
59
+ event.data[:attributes] || event.data["attributes"] || {}
60
+ end
61
+
62
+ def event_changes(event)
63
+ return event.changes if event.respond_to?(:changes) && event.method(:changes).owner != ActiveRecord::AttributeMethods::Dirty rescue event.changes
64
+ event.data[:changes] || event.data["changes"] || {}
65
+ end
66
+
67
+ def event_timestamp(event)
68
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
69
+ event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
70
+ end
71
+ end
72
+
73
+ # Audit trail projection
74
+ class AuditProjection < Projection
75
+ def self.audit_trail(model_class, model_id)
76
+ stream_name = "#{model_class.name}$#{model_id}"
77
+ events = Lyra.config.event_store.read.stream(stream_name).to_a
78
+
79
+ events.map do |event|
80
+ # Access data with both symbol and string keys (JSON serializer uses strings)
81
+ data = event.data
82
+ nested_metadata = data[:metadata] || data["metadata"] || {}
83
+
84
+ {
85
+ operation: data[:operation] || data["operation"],
86
+ timestamp: data[:timestamp] || data["timestamp"] || event.metadata[:timestamp],
87
+ user_id: nested_metadata[:user_id] || nested_metadata["user_id"],
88
+ changes: data[:changes] || data["changes"] || {},
89
+ attributes: data[:attributes] || data["attributes"] || {}
90
+ }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Projections
5
+ # ActiveJob for asynchronous projection processing.
6
+ #
7
+ # When projection_mode is :async, events are projected to model tables
8
+ # in background jobs rather than synchronously during the request.
9
+ #
10
+ # Benefits:
11
+ # - Faster response times (don't wait for projection)
12
+ # - Better fault isolation (projection failures don't fail requests)
13
+ # - Retry capability for transient failures
14
+ #
15
+ # Trade-offs:
16
+ # - Eventual consistency (reads may not see latest writes immediately)
17
+ # - Requires background job infrastructure (Sidekiq, etc.)
18
+ #
19
+ # Usage:
20
+ # AsyncProjectionJob.perform_later(event_id, 'Registration', :create)
21
+ #
22
+ class AsyncProjectionJob < ActiveJob::Base
23
+ queue_as :lyra_projections
24
+
25
+ # Retry with exponential backoff for transient failures
26
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
27
+
28
+ # Don't retry on permanent failures
29
+ discard_on ActiveRecord::RecordNotFound
30
+
31
+ def perform(event_id, model_class_name, operation)
32
+ event = load_event(event_id)
33
+ return unless event
34
+
35
+ model_class = model_class_name.constantize
36
+
37
+ # Build a CommandResult-like structure from the event
38
+ result = build_result_from_event(event, operation)
39
+
40
+ # Project to model table
41
+ ModelProjection.project(model_class, operation.to_sym, result)
42
+ end
43
+
44
+ private
45
+
46
+ def load_event(event_id)
47
+ Lyra.config.event_store.read.event(event_id)
48
+ rescue RubyEventStore::EventNotFound
49
+ Rails.logger.warn("Lyra::AsyncProjectionJob: Event #{event_id} not found")
50
+ nil
51
+ end
52
+
53
+ def build_result_from_event(event, operation)
54
+ # Create a simple struct that looks like CommandResult
55
+ Struct.new(:events, :attributes, :success?, keyword_init: true).new(
56
+ events: [event],
57
+ attributes: event.data[:attributes] || event.data["attributes"] || {},
58
+ success?: true
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end