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