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,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Projections
5
+ # Cached projection layer using Solid Cache (or any Rails.cache backend).
6
+ #
7
+ # In disabled projection mode, this caches reconstructed model state from events.
8
+ # Provides fast reads while keeping events as the source of truth.
9
+ #
10
+ # Cache Strategy:
11
+ # - Individual records: cached by model class + id
12
+ # - Collections: cached by query fingerprint
13
+ # - Invalidation: on event publish for affected streams
14
+ #
15
+ # Usage:
16
+ # CachedProjection.find(User, 123)
17
+ # CachedProjection.where(User, status: "active")
18
+ # CachedProjection.invalidate(User, 123)
19
+ #
20
+ class CachedProjection
21
+ # Cache configuration
22
+ CACHE_VERSION = 1
23
+ DEFAULT_EXPIRES_IN = 1.hour
24
+
25
+ class << self
26
+ # Find a single record by ID (cached)
27
+ #
28
+ # @param model_class [Class] The ActiveRecord model class
29
+ # @param id [Integer, String] The record ID
30
+ # @param force [Boolean] Bypass cache and rebuild
31
+ # @return [Hash, nil] The record attributes or nil
32
+ def find(model_class, id, force: false)
33
+ cache_key = record_cache_key(model_class, id)
34
+
35
+ if force
36
+ result = build_from_events(model_class, id)
37
+ cache_write(cache_key, result) if result
38
+ result
39
+ else
40
+ cache_fetch(cache_key) do
41
+ build_from_events(model_class, id)
42
+ end
43
+ end
44
+ end
45
+
46
+ # Find a record by attributes (cached)
47
+ #
48
+ # @param model_class [Class] The ActiveRecord model class
49
+ # @param attributes [Hash] The attributes to match
50
+ # @return [Hash, nil] The first matching record attributes or nil
51
+ def find_by(model_class, attributes)
52
+ # Optimization: if looking up by :id only, use the fast find() path
53
+ # which uses single-stream lookup instead of scanning all events
54
+ if attributes.size == 1 && (attributes.key?(:id) || attributes.key?("id"))
55
+ id = attributes[:id] || attributes["id"]
56
+ return find(model_class, id)
57
+ end
58
+
59
+ cache_key = query_cache_key(model_class, :find_by, attributes)
60
+
61
+ cache_fetch(cache_key, expires_in: 5.minutes) do
62
+ build_find_by_from_events(model_class, attributes)
63
+ end
64
+ end
65
+
66
+ # Get all records (cached, use with caution on large datasets)
67
+ #
68
+ # @param model_class [Class] The ActiveRecord model class
69
+ # @return [Array<Hash>] All record attributes
70
+ def all(model_class)
71
+ cache_key = collection_cache_key(model_class, :all)
72
+
73
+ cache_fetch(cache_key, expires_in: 5.minutes) do
74
+ build_all_from_events(model_class)
75
+ end
76
+ end
77
+
78
+ # Query records with conditions (cached)
79
+ #
80
+ # @param model_class [Class] The ActiveRecord model class
81
+ # @param conditions [Hash] Query conditions
82
+ # @return [Array<Hash>] Matching record attributes
83
+ def where(model_class, conditions)
84
+ cache_key = query_cache_key(model_class, :where, conditions)
85
+
86
+ cache_fetch(cache_key, expires_in: 5.minutes) do
87
+ build_where_from_events(model_class, conditions)
88
+ end
89
+ end
90
+
91
+ # Count records (cached)
92
+ #
93
+ # @param model_class [Class] The ActiveRecord model class
94
+ # @param conditions [Hash] Optional conditions
95
+ # @return [Integer] Count of matching records
96
+ def count(model_class, conditions = {})
97
+ if conditions.empty?
98
+ cache_key = collection_cache_key(model_class, :count)
99
+ else
100
+ cache_key = query_cache_key(model_class, :count, conditions)
101
+ end
102
+
103
+ cache_fetch(cache_key, expires_in: 1.minute) do
104
+ build_count_from_events(model_class, conditions)
105
+ end
106
+ end
107
+
108
+ # Check if a record exists (cached)
109
+ #
110
+ # @param model_class [Class] The ActiveRecord model class
111
+ # @param id [Integer, String] The record ID
112
+ # @return [Boolean] True if record exists and not destroyed
113
+ def exists?(model_class, id)
114
+ # Use find - if it returns data, it exists
115
+ find(model_class, id).present?
116
+ end
117
+
118
+ # Invalidate cache for a specific record
119
+ #
120
+ # @param model_class [Class] The ActiveRecord model class
121
+ # @param id [Integer, String] The record ID
122
+ def invalidate(model_class, id)
123
+ # Invalidate individual record cache
124
+ cache_delete(record_cache_key(model_class, id))
125
+
126
+ # Invalidate collection caches (they may contain this record)
127
+ invalidate_collection_caches(model_class)
128
+ end
129
+
130
+ # Invalidate all caches for a model class
131
+ #
132
+ # @param model_class [Class] The ActiveRecord model class
133
+ def invalidate_all(model_class)
134
+ # Use cache key prefix deletion if available
135
+ prefix = "#{cache_namespace}/#{model_class.name}/"
136
+
137
+ if cache_store.respond_to?(:delete_matched)
138
+ cache_store.delete_matched("#{prefix}*")
139
+ else
140
+ # Fallback: invalidate known collection keys
141
+ invalidate_collection_caches(model_class)
142
+ end
143
+ end
144
+
145
+ # Warm the cache for a record (call after event is stored)
146
+ #
147
+ # Also invalidates collection caches (all, count) to ensure
148
+ # accurate counts after creates/updates.
149
+ #
150
+ # @param model_class [Class] The ActiveRecord model class
151
+ # @param id [Integer, String] The record ID
152
+ def warm(model_class, id)
153
+ # Rebuild the individual record cache
154
+ find(model_class, id, force: true)
155
+
156
+ # Invalidate collection caches so count/all queries are rebuilt
157
+ # This ensures assert_difference "Model.count" works correctly
158
+ invalidate_collection_caches(model_class)
159
+ end
160
+
161
+ private
162
+
163
+ # Build record state from events
164
+ def build_from_events(model_class, id)
165
+ stream_name = "#{model_class.name}$#{id}"
166
+ events = load_events(stream_name)
167
+
168
+ return nil if events.empty?
169
+ return nil if events.last.event_type.end_with?("Destroyed")
170
+
171
+ reconstruct_state(model_class, id, events)
172
+ end
173
+
174
+ # Build find_by result from events
175
+ def build_find_by_from_events(model_class, attributes)
176
+ # Get all records and filter
177
+ all_records = build_all_from_events(model_class)
178
+
179
+ all_records.find do |record|
180
+ attributes.all? do |key, value|
181
+ record[key.to_s] == value || record[key.to_sym] == value
182
+ end
183
+ end
184
+ end
185
+
186
+ # Build all records from events
187
+ def build_all_from_events(model_class)
188
+ stream_prefix = "#{model_class.name}$"
189
+ streams = find_streams_with_prefix(stream_prefix)
190
+
191
+ streams.filter_map do |stream_name|
192
+ id = stream_name.sub(stream_prefix, "")
193
+ build_from_events(model_class, id)
194
+ end
195
+ end
196
+
197
+ # Build where result from events
198
+ def build_where_from_events(model_class, conditions)
199
+ all_records = build_all_from_events(model_class)
200
+
201
+ all_records.select do |record|
202
+ conditions.all? do |key, value|
203
+ record_value = record[key.to_s] || record[key.to_sym]
204
+
205
+ case value
206
+ when Array
207
+ value.include?(record_value)
208
+ when Range
209
+ value.cover?(record_value)
210
+ else
211
+ record_value == value
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # Build count from events
218
+ def build_count_from_events(model_class, conditions)
219
+ if conditions.empty?
220
+ build_all_from_events(model_class).size
221
+ else
222
+ build_where_from_events(model_class, conditions).size
223
+ end
224
+ end
225
+
226
+ # Reconstruct record state from events
227
+ def reconstruct_state(model_class, id, events)
228
+ attributes = { "id" => id }
229
+
230
+ events.each do |event|
231
+ case event.event_type
232
+ when /Created$/
233
+ event_attrs = event.data[:attributes] || event.data["attributes"] || {}
234
+ attributes.merge!(stringify_keys(event_attrs))
235
+ when /Updated$/
236
+ changes = event.data[:changes] || event.data["changes"] || {}
237
+ changes.each do |field, change|
238
+ new_value = change.is_a?(Array) ? change.last : change
239
+ attributes[field.to_s] = new_value
240
+ end
241
+ end
242
+ end
243
+
244
+ # Convert id to integer if needed
245
+ attributes["id"] = id.to_i if id.to_s =~ /^\d+$/
246
+ attributes
247
+ end
248
+
249
+ # Load events from stream
250
+ def load_events(stream_name)
251
+ Lyra.config.event_store.read.stream(stream_name).to_a
252
+ rescue RubyEventStore::StreamNotFound
253
+ []
254
+ end
255
+
256
+ # Find all streams matching prefix
257
+ def find_streams_with_prefix(prefix)
258
+ # Query event store for streams
259
+ ActiveRecord::Base.connection.select_values(
260
+ "SELECT DISTINCT stream FROM event_store_events_in_streams WHERE stream LIKE '#{prefix}%'"
261
+ )
262
+ rescue StandardError => e
263
+ Rails.logger.warn("Lyra::CachedProjection: Stream enumeration failed - #{e.message}")
264
+ []
265
+ end
266
+
267
+ # Cache key helpers
268
+ def record_cache_key(model_class, id)
269
+ "#{cache_namespace}/#{model_class.name}/records/#{id}/v#{CACHE_VERSION}"
270
+ end
271
+
272
+ def collection_cache_key(model_class, operation)
273
+ "#{cache_namespace}/#{model_class.name}/collections/#{operation}/v#{CACHE_VERSION}"
274
+ end
275
+
276
+ def query_cache_key(model_class, operation, params)
277
+ fingerprint = Digest::SHA256.hexdigest(params.to_json)[0..15]
278
+ "#{cache_namespace}/#{model_class.name}/queries/#{operation}/#{fingerprint}/v#{CACHE_VERSION}"
279
+ end
280
+
281
+ def cache_namespace
282
+ "lyra_projections"
283
+ end
284
+
285
+ # Cache operations (uses Rails.cache which can be Solid Cache)
286
+ def cache_store
287
+ Rails.cache
288
+ end
289
+
290
+ def cache_fetch(key, expires_in: DEFAULT_EXPIRES_IN, &block)
291
+ cache_store.fetch(key, expires_in: expires_in, &block)
292
+ end
293
+
294
+ def cache_write(key, value, expires_in: DEFAULT_EXPIRES_IN)
295
+ cache_store.write(key, value, expires_in: expires_in)
296
+ end
297
+
298
+ def cache_read(key)
299
+ cache_store.read(key)
300
+ end
301
+
302
+ def cache_delete(key)
303
+ cache_store.delete(key)
304
+ end
305
+
306
+ def invalidate_collection_caches(model_class)
307
+ # Invalidate known collection operations
308
+ [:all, :count].each do |op|
309
+ cache_delete(collection_cache_key(model_class, op))
310
+ end
311
+
312
+ # Note: Query caches (where, find_by) expire quickly (5 min)
313
+ # so we don't need to explicitly invalidate them
314
+ end
315
+
316
+ def stringify_keys(hash)
317
+ hash.transform_keys(&:to_s)
318
+ end
319
+ end
320
+ end
321
+ end
322
+ end