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,543 @@
1
+ module Lyra
2
+ module Interceptors
3
+ module CrudInterceptor
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ # Track if this model is being monitored
8
+ class_attribute :lyra_monitored, default: false
9
+ class_attribute :lyra_config
10
+
11
+ # Callbacks for monitoring CRUD operations (after_* = non-blocking)
12
+ after_create :lyra_intercept_create, if: :lyra_monitored?
13
+ after_update :lyra_intercept_update, if: :lyra_monitored?
14
+ after_destroy :lyra_intercept_destroy, if: :lyra_monitored?
15
+
16
+ # For hijack mode - run before operation, store event, then let save proceed
17
+ before_create :lyra_hijack_create, if: :lyra_hijack_mode?
18
+ before_update :lyra_hijack_update, if: :lyra_hijack_mode?
19
+ before_destroy :lyra_hijack_destroy, if: :lyra_hijack_mode?
20
+
21
+ # For event_sourcing mode - prepare for event sourcing (NO throw(:abort)!)
22
+ before_create :lyra_prepare_event_source_create, if: :lyra_event_sourcing_mode?
23
+ before_update :lyra_prepare_event_source_update, if: :lyra_event_sourcing_mode?
24
+ before_destroy :lyra_prepare_event_source_destroy, if: :lyra_event_sourcing_mode?
25
+
26
+ # After callbacks to finalize event sourcing
27
+ after_create :lyra_finalize_event_source, if: :lyra_event_sourcing_mode?
28
+ after_update :lyra_finalize_event_source, if: :lyra_event_sourcing_mode?
29
+ after_destroy :lyra_finalize_event_source, if: :lyra_event_sourcing_mode?
30
+ end
31
+
32
+ # Override _update_row to skip SQL UPDATE in event_sourcing mode.
33
+ # Rails 6.1+ calls _update_row from within _run_update_callbacks,
34
+ # so this runs AFTER before_update callbacks when @lyra_skip_sql is set.
35
+ def _update_row(*)
36
+ if @lyra_skip_sql
37
+ # Skip the UPDATE - projection will handle it
38
+ # Return 1 to indicate one row was "updated"
39
+ 1
40
+ else
41
+ super
42
+ end
43
+ end
44
+
45
+ # Override _delete_row to skip SQL DELETE in event_sourcing mode.
46
+ # Similar to _update_row, runs after before_destroy callbacks.
47
+ def _delete_row
48
+ if @lyra_skip_sql
49
+ # Skip the DELETE - projection will handle it
50
+ # Return 1 to indicate one row was "deleted"
51
+ 1
52
+ else
53
+ super
54
+ end
55
+ end
56
+
57
+ class_methods do
58
+ # Enable Lyra monitoring for this model
59
+ def monitor_with_lyra(options = {})
60
+ self.lyra_monitored = true
61
+ self.lyra_config = Lyra::ModelConfiguration.new(self, options)
62
+ Lyra.config.monitor_model(self, options)
63
+
64
+ # Prepend strict data access modules to guard callback-bypassing methods
65
+ # Using prepend ensures our methods run first and can call super
66
+ prepend Lyra::StrictDataAccess
67
+ singleton_class.prepend Lyra::StrictDataAccessClassMethods
68
+ end
69
+
70
+ # Override _insert_record to skip SQL INSERT when Lyra signals to skip.
71
+ # Uses Thread.current to receive signal from instance-level before_create.
72
+ def _insert_record(connection, values, returning)
73
+ if Thread.current[:lyra_skip_insert]
74
+ # Skip the INSERT - projection will handle it
75
+ # Return the pre-assigned ID as the "returning" value
76
+ id_value = values[primary_key] || values[primary_key.to_sym]
77
+ returning.map { |col| col == primary_key ? id_value : nil }
78
+ else
79
+ super
80
+ end
81
+ end
82
+
83
+ # =======================================================================
84
+ # Read overrides for disabled projection mode
85
+ # When projection_mode is :disabled, read from event store instead of DB
86
+ # =======================================================================
87
+
88
+ def find(*args)
89
+ if lyra_read_from_events?
90
+ id = args.first
91
+ record = Lyra::Projections::EventStoreReader.find(self, id)
92
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}", self, primary_key, id) unless record
93
+ record
94
+ else
95
+ super
96
+ end
97
+ end
98
+
99
+ def find_by(attributes)
100
+ if lyra_read_from_events?
101
+ Lyra::Projections::EventStoreReader.find_by(self, attributes)
102
+ else
103
+ super
104
+ end
105
+ end
106
+
107
+ def find_by!(attributes)
108
+ if lyra_read_from_events?
109
+ record = Lyra::Projections::EventStoreReader.find_by(self, attributes)
110
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{name}", self) unless record
111
+ record
112
+ else
113
+ super
114
+ end
115
+ end
116
+
117
+ def exists?(conditions = :none)
118
+ if lyra_read_from_events? && (conditions.is_a?(Integer) || conditions.is_a?(String))
119
+ Lyra::Projections::EventStoreReader.exists?(self, conditions)
120
+ else
121
+ super
122
+ end
123
+ end
124
+
125
+ # Override where to use cached projections in disabled mode
126
+ # Returns a CachedRelation that supports method chaining
127
+ def where(...)
128
+ if lyra_read_from_events?
129
+ Lyra::Projections::EventStoreReader.relation(self).where(...)
130
+ else
131
+ super
132
+ end
133
+ end
134
+
135
+ # Override all to use cached projections in disabled mode
136
+ # Note: Rails 8 passes arguments to all() in internal call chains (e.g., reload)
137
+ def all(...)
138
+ if lyra_read_from_events?
139
+ Lyra::Projections::EventStoreReader.all(self)
140
+ else
141
+ super
142
+ end
143
+ end
144
+
145
+ # Override first/last to properly order by primary key
146
+ # Rails' Model.first is equivalent to Model.order(pk: :asc).limit(1).first
147
+ # Rails' Model.last is equivalent to Model.order(pk: :desc).limit(1).first
148
+ def first(limit = nil)
149
+ if lyra_read_from_events?
150
+ relation = Lyra::Projections::EventStoreReader.relation(self)
151
+ relation.order(primary_key => :asc).first(limit)
152
+ else
153
+ super
154
+ end
155
+ end
156
+
157
+ def last(limit = nil)
158
+ if lyra_read_from_events?
159
+ relation = Lyra::Projections::EventStoreReader.relation(self)
160
+ relation.order(primary_key => :desc).first(limit)
161
+ else
162
+ super
163
+ end
164
+ end
165
+
166
+ # NOTE: We intentionally do NOT override unscoped here.
167
+ # Association loading is handled by AssociationInterceptor instead,
168
+ # which patches AR's BelongsTo/HasOne/HasMany association classes directly.
169
+ # This avoids compatibility issues with AR's internal scope building machinery.
170
+
171
+ private
172
+
173
+ def lyra_read_from_events?
174
+ return false if Thread.current[:lyra_bypass_read_override]
175
+ lyra_monitored &&
176
+ Lyra.event_sourcing_mode? &&
177
+ Lyra.config.projection_mode == :disabled
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def lyra_monitored?
184
+ self.class.lyra_monitored
185
+ end
186
+
187
+ def lyra_hijack_mode?
188
+ lyra_monitored? && Lyra.hijack_mode?
189
+ end
190
+
191
+ def lyra_event_sourcing_mode?
192
+ lyra_monitored? && Lyra.event_sourcing_mode?
193
+ end
194
+
195
+ # MONITOR MODE: After callbacks that log events
196
+ def lyra_intercept_create
197
+ return if @lyra_hijacked
198
+
199
+ event_data = build_event_data(:created)
200
+ publish_event(:created, event_data)
201
+ end
202
+
203
+ def lyra_intercept_update
204
+ return if @lyra_hijacked
205
+
206
+ event_data = build_event_data(:updated)
207
+ publish_event(:updated, event_data)
208
+ end
209
+
210
+ def lyra_intercept_destroy
211
+ return if @lyra_hijacked
212
+
213
+ event_data = build_event_data(:destroyed)
214
+ publish_event(:destroyed, event_data)
215
+ end
216
+
217
+ # HIJACK MODE: Before callbacks that can override behavior
218
+ def lyra_hijack_create
219
+ @lyra_hijacked = true
220
+ lyra_disable_paper_trail!
221
+
222
+ command = Lyra::Commands::CreateCommand.new(self.class, attributes)
223
+ result = Lyra::CommandHandler.handle(command)
224
+
225
+ if result.success?
226
+ # Update the model with the result from event sourcing
227
+ assign_attributes(result.attributes)
228
+ else
229
+ errors.add(:base, result.error)
230
+ throw(:abort)
231
+ end
232
+ end
233
+
234
+ def lyra_hijack_update
235
+ @lyra_hijacked = true
236
+ lyra_disable_paper_trail!
237
+
238
+ command = Lyra::Commands::UpdateCommand.new(self.class, id, changes)
239
+ result = Lyra::CommandHandler.handle(command)
240
+
241
+ unless result.success?
242
+ errors.add(:base, result.error)
243
+ throw(:abort)
244
+ end
245
+ end
246
+
247
+ def lyra_hijack_destroy
248
+ @lyra_hijacked = true
249
+ lyra_disable_paper_trail!
250
+
251
+ command = Lyra::Commands::DestroyCommand.new(self.class, id)
252
+ result = Lyra::CommandHandler.handle(command)
253
+
254
+ unless result.success?
255
+ errors.add(:base, result.error)
256
+ throw(:abort)
257
+ end
258
+ end
259
+
260
+ # EVENT SOURCING MODE: Prepare phase (before callbacks)
261
+ # Generate ID, create event, mark to skip SQL.
262
+ # For creates: Thread.current[:lyra_skip_insert] signals _insert_record to skip.
263
+ # For updates/deletes: @lyra_skip_sql causes _update_row/_delete_row to skip.
264
+ # The sync projection handles persistence instead.
265
+ def lyra_prepare_event_source_create
266
+ @lyra_hijacked = true
267
+ lyra_disable_paper_trail!
268
+
269
+ command = Lyra::Commands::CreateCommand.new(self.class, attributes)
270
+ result = Lyra::CommandHandler.handle(command)
271
+
272
+ if result.success?
273
+ # Store result for after callback
274
+ @lyra_event_result = result
275
+ @lyra_event_operation = :create
276
+ @lyra_assigned_id = result.attributes[:id]
277
+
278
+ # Assign the pre-generated ID
279
+ self.id = @lyra_assigned_id if @lyra_assigned_id
280
+
281
+ # Signal class method _insert_record to skip the SQL INSERT
282
+ Thread.current[:lyra_skip_insert] = true
283
+ else
284
+ errors.add(:base, result.error)
285
+ throw(:abort) # This is OK for actual errors
286
+ end
287
+ end
288
+
289
+ def lyra_prepare_event_source_update
290
+ @lyra_hijacked = true
291
+ lyra_disable_paper_trail!
292
+
293
+ command = Lyra::Commands::UpdateCommand.new(self.class, id, changes)
294
+ result = Lyra::CommandHandler.handle(command)
295
+
296
+ if result.success?
297
+ @lyra_event_result = result
298
+ @lyra_event_operation = :update
299
+ @lyra_skip_sql = true
300
+ else
301
+ errors.add(:base, result.error)
302
+ throw(:abort)
303
+ end
304
+ end
305
+
306
+ def lyra_prepare_event_source_destroy
307
+ @lyra_hijacked = true
308
+ lyra_disable_paper_trail!
309
+
310
+ command = Lyra::Commands::DestroyCommand.new(self.class, id)
311
+ result = Lyra::CommandHandler.handle(command)
312
+
313
+ if result.success?
314
+ @lyra_event_result = result
315
+ @lyra_event_operation = :destroy
316
+ @lyra_skip_sql = true
317
+ else
318
+ errors.add(:base, result.error)
319
+ throw(:abort)
320
+ end
321
+ end
322
+
323
+ # EVENT SOURCING MODE: Finalize phase (after callbacks)
324
+ # Store events and run projections
325
+ def lyra_finalize_event_source
326
+ return unless @lyra_event_result
327
+
328
+ # Store events to the event store
329
+ if @lyra_event_result.events&.any?
330
+ lyra_store_events(@lyra_event_result.events)
331
+ end
332
+
333
+ # Run projection based on configured mode
334
+ case Lyra.config.projection_mode
335
+ when :sync
336
+ lyra_run_sync_projection(@lyra_event_operation, @lyra_event_result)
337
+ when :async
338
+ lyra_enqueue_async_projection(@lyra_event_operation, @lyra_event_result)
339
+ when :disabled
340
+ # Warm cache with the new data (uses Solid Cache or Rails.cache)
341
+ lyra_warm_cache(@lyra_event_operation, @lyra_event_result)
342
+ end
343
+
344
+ # Clear state
345
+ @lyra_event_result = nil
346
+ @lyra_event_operation = nil
347
+ @lyra_skip_sql = false
348
+ Thread.current[:lyra_skip_insert] = nil
349
+ end
350
+
351
+ # Run synchronous projection (update model table from event)
352
+ def lyra_run_sync_projection(operation, result)
353
+ # Record for read-your-writes consistency if in guaranteed block
354
+ if Lyra::Consistency::ReadYourWrites.in_guaranteed_block?
355
+ Lyra::Consistency::ReadYourWrites.record_write(self.class, operation, result)
356
+ return # Will be projected at end of block
357
+ end
358
+
359
+ Lyra::Projections::ModelProjection.project(self.class, operation, result)
360
+ rescue => e
361
+ if Lyra.config.strict_projections
362
+ raise
363
+ else
364
+ Rails.logger.error("Lyra: Sync projection failed - #{e.message}")
365
+ Lyra.config.projection_error_handler&.call(e, self, operation)
366
+ end
367
+ end
368
+
369
+ # Enqueue asynchronous projection job
370
+ def lyra_enqueue_async_projection(operation, result)
371
+ event = result.events&.first
372
+ return unless event
373
+
374
+ # In test environment, run synchronously for immediate consistency
375
+ if Rails.env.test? || Lyra.config.async_projections_inline
376
+ Lyra::Projections::ModelProjection.project(self.class, operation, result)
377
+ else
378
+ Lyra::Projections::AsyncProjectionJob.perform_later(
379
+ event.event_id,
380
+ self.class.name,
381
+ operation.to_s
382
+ )
383
+ end
384
+ rescue => e
385
+ if Lyra.config.strict_projections
386
+ raise
387
+ else
388
+ Rails.logger.error("Lyra: Failed to enqueue async projection - #{e.message}")
389
+ Lyra.config.projection_error_handler&.call(e, self, operation)
390
+ end
391
+ end
392
+
393
+ # Warm cache after event stored (for disabled projection mode)
394
+ # Uses Solid Cache (or any Rails.cache backend) for fast reads
395
+ def lyra_warm_cache(operation, result)
396
+ model_id = case operation
397
+ when :create
398
+ result.attributes[:id] || result.attributes["id"]
399
+ when :update, :destroy
400
+ event = result.events&.first
401
+ event&.data&.dig(:model_id) || event&.data&.dig("model_id")
402
+ end
403
+
404
+ return unless model_id
405
+
406
+ if operation == :destroy
407
+ # Invalidate cache for destroyed record
408
+ Lyra::Projections::EventStoreReader.invalidate(self.class, model_id)
409
+ else
410
+ # Warm cache with new/updated data
411
+ Lyra::Projections::EventStoreReader.warm(self.class, model_id)
412
+ end
413
+ rescue => e
414
+ Rails.logger.warn("Lyra: Failed to warm cache - #{e.message}")
415
+ end
416
+
417
+ # Store events to the event store
418
+ def lyra_store_events(events)
419
+ stream_name = lyra_stream_name
420
+ events.each do |event|
421
+ Lyra.config.event_store.publish(event, stream_name: stream_name)
422
+ end
423
+ rescue => e
424
+ if Lyra.config.strict_projections
425
+ raise
426
+ else
427
+ Rails.logger.error("Lyra: Failed to store events - #{e.message}")
428
+ end
429
+ end
430
+
431
+ # Disable PaperTrail for this record in hijack/event_sourcing mode
432
+ # (Lyra's event sourcing replaces PaperTrail's audit trail)
433
+ def lyra_disable_paper_trail!
434
+ return unless defined?(PaperTrail)
435
+
436
+ # PaperTrail 17+ uses PaperTrail.request.enabled
437
+ # This disables versioning for the current request/thread
438
+ if PaperTrail.respond_to?(:request)
439
+ PaperTrail.request.enabled = false
440
+ elsif respond_to?(:paper_trail) && paper_trail.respond_to?(:enabled=)
441
+ # Older PaperTrail versions use instance-level enabled
442
+ paper_trail.enabled = false
443
+ end
444
+ rescue => e
445
+ # Silently ignore if PaperTrail integration fails
446
+ end
447
+
448
+ def build_event_data(operation)
449
+ # Base metadata from built-in context
450
+ base_metadata = {
451
+ user_id: lyra_current_user_id,
452
+ request_id: lyra_current_request_id,
453
+ correlation_id: Lyra::Correlation.current_id,
454
+ causation_id: Lyra::Causation.current_id,
455
+ action_id: lyra_current_action_id,
456
+ user_action: lyra_current_user_action
457
+ }
458
+
459
+ # Merge custom metadata from metadata_proc if configured
460
+ custom_metadata = lyra_custom_metadata(operation)
461
+ merged_metadata = base_metadata.merge(custom_metadata)
462
+
463
+ {
464
+ model_class: self.class.name,
465
+ model_id: id,
466
+ operation: operation,
467
+ attributes: attributes.except("created_at", "updated_at"),
468
+ changes: previous_changes,
469
+ timestamp: Time.current,
470
+ metadata: merged_metadata
471
+ }
472
+ end
473
+
474
+ # Get custom metadata from configured metadata_proc
475
+ def lyra_custom_metadata(operation)
476
+ return {} unless Lyra.config.metadata_proc
477
+
478
+ begin
479
+ result = Lyra.config.metadata_proc.call(self, operation)
480
+ result.is_a?(Hash) ? result : {}
481
+ rescue => e
482
+ Rails.logger.warn("Lyra: metadata_proc failed - #{e.message}")
483
+ {}
484
+ end
485
+ end
486
+
487
+ def publish_event(operation, data)
488
+ event_class = lyra_event_class_for(operation)
489
+
490
+ # Extract metadata from data and pass separately to RailsEventStore
491
+ metadata = data.delete(:metadata) || {}
492
+ event = event_class.new(data: data, metadata: metadata)
493
+
494
+ Lyra.config.event_store.publish(event, stream_name: lyra_stream_name)
495
+ rescue => e
496
+ Rails.logger.error("Lyra: Failed to publish event - #{e.message}")
497
+ # Don't fail the CRUD operation if event publishing fails in monitor mode
498
+ end
499
+
500
+ def lyra_event_class_for(operation)
501
+ config = self.class.lyra_config || Lyra.config.model_config(self.class)
502
+ event_name = config.event_name_for(operation)
503
+ # Sanitize namespaced event names (e.g., "Spree::OrderCreated" -> "SpreeOrderCreated")
504
+ sanitized_name = event_name.to_s.gsub("::", "")
505
+
506
+ # Find or create the event class in Lyra::Events namespace
507
+ if Lyra::Events.const_defined?(sanitized_name, false)
508
+ Lyra::Events.const_get(sanitized_name, false)
509
+ else
510
+ Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
511
+ end
512
+ end
513
+
514
+ def lyra_stream_name
515
+ "#{self.class.name}$#{id}"
516
+ end
517
+
518
+ # Hook methods for context (can be overridden by the application)
519
+ def lyra_current_user_id
520
+ defined?(Current) && Current.respond_to?(:user) ? Current.user&.id : nil
521
+ end
522
+
523
+ def lyra_current_request_id
524
+ defined?(Current) && Current.respond_to?(:request_id) ? Current.request_id : nil
525
+ end
526
+
527
+ def lyra_current_action_id
528
+ Lyra::UserActionContext.current&.action_id
529
+ end
530
+
531
+ def lyra_current_user_action
532
+ context = Lyra::UserActionContext.current
533
+ return nil unless context
534
+
535
+ {
536
+ type: context.action_type,
537
+ controller: context.controller,
538
+ action: context.action_name
539
+ }
540
+ end
541
+ end
542
+ end
543
+ end
@@ -0,0 +1,161 @@
1
+ module Lyra
2
+ module Privacy
3
+ # GDPR compliance tools for Lyra events
4
+ #
5
+ # This class provides a Lyra-specific wrapper around PamDsl::GDPRCompliance,
6
+ # automatically configuring it to work with Lyra's event store and event format.
7
+ #
8
+ # @example Basic usage
9
+ # compliance = Lyra::Privacy::GDPRCompliance.new(subject_id: user.id)
10
+ # report = compliance.data_export
11
+ #
12
+ # @example With custom subject type
13
+ # compliance = Lyra::Privacy::GDPRCompliance.new(
14
+ # subject_id: student.id,
15
+ # subject_type: 'Student'
16
+ # )
17
+ #
18
+ class GDPRCompliance
19
+ attr_reader :pam_compliance
20
+
21
+ # Initialize GDPR compliance handler
22
+ #
23
+ # @param subject_id [Object] The data subject's identifier
24
+ # @param subject_type [String] The type/class of the subject (default: 'User')
25
+ #
26
+ def initialize(subject_id:, subject_type: 'User')
27
+ @subject_id = subject_id
28
+ @subject_type = subject_type
29
+
30
+ @pam_compliance = PamDsl::GDPRCompliance.new(
31
+ subject_id: subject_id,
32
+ subject_type: subject_type,
33
+ event_reader: method(:read_subject_events),
34
+ attribute_extractor: method(:extract_attributes),
35
+ timestamp_extractor: method(:extract_timestamp),
36
+ operation_extractor: method(:extract_operation),
37
+ model_class_extractor: method(:extract_model_class),
38
+ model_id_extractor: method(:extract_model_id),
39
+ changes_extractor: method(:extract_changes),
40
+ retention_policy: Lyra.config.retention_policy || default_retention_policy
41
+ )
42
+ end
43
+
44
+ # Delegate all GDPR methods to the PAM DSL implementation
45
+
46
+ # Right to Access (Article 15)
47
+ def data_export
48
+ pam_compliance.data_export
49
+ end
50
+
51
+ # Right to be Forgotten (Article 17)
52
+ def right_to_be_forgotten_report
53
+ pam_compliance.right_to_be_forgotten_report
54
+ end
55
+
56
+ # Right to Data Portability (Article 20)
57
+ def portable_export(format: :json)
58
+ pam_compliance.portable_export(format: format)
59
+ end
60
+
61
+ # Right to Rectification (Article 16)
62
+ def rectification_history
63
+ pam_compliance.rectification_history
64
+ end
65
+
66
+ # Processing Activities Record (Article 30)
67
+ def processing_activities
68
+ pam_compliance.processing_activities
69
+ end
70
+
71
+ # Data Retention Compliance Check
72
+ def retention_compliance_check
73
+ pam_compliance.retention_compliance_check
74
+ end
75
+
76
+ # Consent Audit
77
+ def consent_audit
78
+ pam_compliance.consent_audit
79
+ end
80
+
81
+ # Full GDPR Compliance Report
82
+ def full_report
83
+ pam_compliance.full_report
84
+ end
85
+
86
+ private
87
+
88
+ # Read all events related to the subject from Lyra's event store
89
+ def read_subject_events(subject_id, subject_type)
90
+ Lyra.config.event_store.read.to_a.select do |event|
91
+ event_relates_to_subject?(event, subject_id, subject_type)
92
+ end
93
+ end
94
+
95
+ # Check if an event relates to the subject
96
+ def event_relates_to_subject?(event, subject_id, subject_type)
97
+ metadata = event.respond_to?(:metadata) ? event.metadata : {}
98
+ data = event.respond_to?(:data) ? event.data : {}
99
+
100
+ metadata[:user_id] == subject_id ||
101
+ data[:user_id] == subject_id ||
102
+ data[:subject_id] == subject_id ||
103
+ (extract_model_class(event) == subject_type && extract_model_id(event) == subject_id)
104
+ end
105
+
106
+ # Lyra-specific extractors
107
+
108
+ def extract_attributes(event)
109
+ return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Method)
110
+ return event.data[:attributes] || event.data["attributes"] || {} if event.respond_to?(:data)
111
+ {}
112
+ end
113
+
114
+ def extract_timestamp(event)
115
+ return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
116
+ return event.data[:timestamp] || event.data["timestamp"] if event.respond_to?(:data)
117
+ event.metadata[:timestamp] if event.respond_to?(:metadata)
118
+ end
119
+
120
+ def extract_operation(event)
121
+ return event.operation if event.respond_to?(:operation)
122
+ if event.respond_to?(:data)
123
+ op = event.data[:operation] || event.data["operation"]
124
+ op.is_a?(String) ? op.to_sym : op
125
+ end
126
+ end
127
+
128
+ def extract_model_class(event)
129
+ return event.model_class if event.respond_to?(:model_class)
130
+ event.data[:model_class] || event.data["model_class"] if event.respond_to?(:data)
131
+ end
132
+
133
+ def extract_model_id(event)
134
+ return event.model_id if event.respond_to?(:model_id)
135
+ event.data[:model_id] || event.data["model_id"] if event.respond_to?(:data)
136
+ end
137
+
138
+ def extract_changes(event)
139
+ if event.respond_to?(:changes) && !event.changes.is_a?(Method)
140
+ begin
141
+ return event.changes unless event.method(:changes).owner.to_s.include?('ActiveRecord')
142
+ rescue
143
+ return event.changes
144
+ end
145
+ end
146
+ return event.data[:changes] || event.data["changes"] || {} if event.respond_to?(:data)
147
+ {}
148
+ end
149
+
150
+ def default_retention_policy
151
+ {
152
+ default: { duration: 7.years },
153
+ 'Payment' => { duration: 10.years },
154
+ 'Invoice' => { duration: 10.years },
155
+ 'Student' => { duration: 10.years },
156
+ 'Enrollment' => { duration: 10.years }
157
+ }
158
+ end
159
+ end
160
+ end
161
+ end