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