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,72 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Pluggable event store adapter
|
|
3
|
+
class EventStoreAdapter
|
|
4
|
+
class << self
|
|
5
|
+
def build(backend = nil)
|
|
6
|
+
backend ||= Lyra.config.event_backend
|
|
7
|
+
|
|
8
|
+
case backend
|
|
9
|
+
when :rails_event_store, :res
|
|
10
|
+
RailsEventStoreAdapter.new
|
|
11
|
+
when :custom
|
|
12
|
+
CustomEventStoreAdapter.new
|
|
13
|
+
else
|
|
14
|
+
raise "Unknown event backend: #{backend}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class RailsEventStoreAdapter
|
|
21
|
+
def initialize
|
|
22
|
+
@client = build_client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def publish(event, stream_name:)
|
|
26
|
+
@client.publish(event, stream_name: stream_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read_stream(stream_name)
|
|
30
|
+
@client.read.stream(stream_name).to_a
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_all_streams
|
|
34
|
+
@client.read.to_a
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def subscribe(subscriber, to:)
|
|
38
|
+
@client.subscribe(subscriber, to: to)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_client
|
|
44
|
+
RailsEventStore::Client.new(
|
|
45
|
+
repository: RailsEventStoreActiveRecord::EventRepository.new(
|
|
46
|
+
serializer: RailsEventStore::Serializers::YAML
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class CustomEventStoreAdapter
|
|
53
|
+
# Placeholder for custom event store implementations
|
|
54
|
+
# Users can implement their own adapter by extending this class
|
|
55
|
+
|
|
56
|
+
def publish(event, stream_name:)
|
|
57
|
+
raise NotImplementedError, "Custom event store adapter must implement #publish"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_stream(stream_name)
|
|
61
|
+
raise NotImplementedError, "Custom event store adapter must implement #read_stream"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def read_all_streams
|
|
65
|
+
raise NotImplementedError, "Custom event store adapter must implement #read_all_streams"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def subscribe(subscriber, to:)
|
|
69
|
+
raise NotImplementedError, "Custom event store adapter must implement #subscribe"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
# Generates IDs for new records before event storage in event_sourcing mode.
|
|
5
|
+
#
|
|
6
|
+
# In event_sourcing mode, we need the record ID before the event is stored
|
|
7
|
+
# (since we abort the actual database save). This class handles ID generation
|
|
8
|
+
# for different database adapters and primary key types.
|
|
9
|
+
#
|
|
10
|
+
# Strategies:
|
|
11
|
+
# - PostgreSQL: Use nextval() to reserve sequence value
|
|
12
|
+
# - SQLite: Use max(id) + 1 (less safe for concurrent writes)
|
|
13
|
+
# - MySQL/Other: Hi-Lo algorithm with configurable block size
|
|
14
|
+
# - UUID columns: Generate SecureRandom.uuid
|
|
15
|
+
#
|
|
16
|
+
class IdGenerator
|
|
17
|
+
# Thread-safe Hi-Lo state storage
|
|
18
|
+
@hilo_mutex = Mutex.new
|
|
19
|
+
@hilo_state = {}
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Generate the next ID for a model class
|
|
23
|
+
#
|
|
24
|
+
# @param model_class [Class] The ActiveRecord model class
|
|
25
|
+
# @return [Integer, String] The generated ID
|
|
26
|
+
def next_id(model_class)
|
|
27
|
+
pk_column = model_class.columns_hash[model_class.primary_key]
|
|
28
|
+
|
|
29
|
+
case pk_column&.type
|
|
30
|
+
when :uuid
|
|
31
|
+
SecureRandom.uuid
|
|
32
|
+
when :integer, :bigint, nil
|
|
33
|
+
next_integer_id(model_class)
|
|
34
|
+
else
|
|
35
|
+
# Default to UUID for unknown types
|
|
36
|
+
SecureRandom.uuid
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def next_integer_id(model_class)
|
|
43
|
+
case adapter_name(model_class)
|
|
44
|
+
when /postgresql/i
|
|
45
|
+
next_postgresql_id(model_class)
|
|
46
|
+
when /mysql/i, /trilogy/i
|
|
47
|
+
next_hilo_id(model_class)
|
|
48
|
+
when /sqlite/i
|
|
49
|
+
next_sqlite_id(model_class)
|
|
50
|
+
else
|
|
51
|
+
next_hilo_id(model_class)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def adapter_name(model_class)
|
|
56
|
+
model_class.connection.adapter_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# PostgreSQL: Reserve next sequence value atomically
|
|
60
|
+
def next_postgresql_id(model_class)
|
|
61
|
+
table = model_class.table_name
|
|
62
|
+
pk = model_class.primary_key
|
|
63
|
+
|
|
64
|
+
# Get the sequence name for this table's primary key
|
|
65
|
+
result = model_class.connection.execute(
|
|
66
|
+
"SELECT pg_get_serial_sequence('#{table}', '#{pk}')"
|
|
67
|
+
)
|
|
68
|
+
sequence = result.first&.values&.first
|
|
69
|
+
|
|
70
|
+
if sequence
|
|
71
|
+
# Reserve the next value from the sequence
|
|
72
|
+
result = model_class.connection.execute("SELECT nextval('#{sequence}')")
|
|
73
|
+
result.first["nextval"].to_i
|
|
74
|
+
else
|
|
75
|
+
# No sequence (maybe not a serial column), fall back to Hi-Lo
|
|
76
|
+
next_hilo_id(model_class)
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.warn("Lyra::IdGenerator: PostgreSQL sequence failed (#{e.message}), using Hi-Lo")
|
|
80
|
+
next_hilo_id(model_class)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# SQLite: Use max(id) + 1 (not ideal for concurrency)
|
|
84
|
+
def next_sqlite_id(model_class)
|
|
85
|
+
table = model_class.table_name
|
|
86
|
+
pk = model_class.primary_key
|
|
87
|
+
|
|
88
|
+
result = model_class.connection.execute(
|
|
89
|
+
"SELECT MAX(#{pk}) as max_id FROM #{table}"
|
|
90
|
+
)
|
|
91
|
+
max_id = result.first&.fetch("max_id", 0) || 0
|
|
92
|
+
max_id.to_i + 1
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Rails.logger.warn("Lyra::IdGenerator: SQLite max failed (#{e.message}), using Hi-Lo")
|
|
95
|
+
next_hilo_id(model_class)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Hi-Lo Algorithm: Reserve blocks of IDs to minimize DB queries
|
|
99
|
+
#
|
|
100
|
+
# This algorithm reserves a block of IDs at once, reducing DB roundtrips.
|
|
101
|
+
# Thread-safe implementation using mutex.
|
|
102
|
+
#
|
|
103
|
+
# @param model_class [Class] The model class
|
|
104
|
+
# @param block_size [Integer] Number of IDs to reserve per hi value (default: 100)
|
|
105
|
+
# @return [Integer] The next ID
|
|
106
|
+
def next_hilo_id(model_class, block_size: 100)
|
|
107
|
+
key = model_class.name
|
|
108
|
+
|
|
109
|
+
@hilo_mutex.synchronize do
|
|
110
|
+
@hilo_state[key] ||= { hi: nil, lo: 0, max_lo: block_size }
|
|
111
|
+
state = @hilo_state[key]
|
|
112
|
+
|
|
113
|
+
# Need to fetch new hi value?
|
|
114
|
+
if state[:hi].nil? || state[:lo] >= state[:max_lo]
|
|
115
|
+
state[:hi] = fetch_next_hi(model_class, block_size)
|
|
116
|
+
state[:lo] = 0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Calculate ID: hi * block_size + lo
|
|
120
|
+
id = (state[:hi] * state[:max_lo]) + state[:lo]
|
|
121
|
+
state[:lo] += 1
|
|
122
|
+
id
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Fetch the next hi value based on current max ID
|
|
127
|
+
def fetch_next_hi(model_class, block_size)
|
|
128
|
+
max_id = model_class.unscoped.maximum(model_class.primary_key) || 0
|
|
129
|
+
# Calculate hi value that puts us above any existing ID
|
|
130
|
+
(max_id / block_size) + 1
|
|
131
|
+
rescue StandardError
|
|
132
|
+
# If query fails, use timestamp-based hi to avoid collisions
|
|
133
|
+
(Time.current.to_i / 100) % 1_000_000
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
module Interceptors
|
|
5
|
+
# Patches AR association loading to use cached projections when:
|
|
6
|
+
# - The target model is monitored by Lyra
|
|
7
|
+
# - Lyra is in event_sourcing mode
|
|
8
|
+
# - projection_mode is :disabled
|
|
9
|
+
#
|
|
10
|
+
# This allows belongs_to/has_one associations to work transparently
|
|
11
|
+
# even when the target record only exists in the cache, not the DB.
|
|
12
|
+
#
|
|
13
|
+
module AssociationInterceptor
|
|
14
|
+
module BelongsToAssociationPatch
|
|
15
|
+
# Rails 8+ passes async: keyword argument to find_target
|
|
16
|
+
def find_target(async: false)
|
|
17
|
+
if lyra_should_use_cache?
|
|
18
|
+
lyra_find_target_from_cache
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def lyra_should_use_cache?
|
|
27
|
+
return false unless Lyra.event_sourcing_mode? && Lyra.config.projection_mode == :disabled
|
|
28
|
+
|
|
29
|
+
target_klass = lyra_resolve_target_class
|
|
30
|
+
return false unless target_klass
|
|
31
|
+
|
|
32
|
+
target_klass.respond_to?(:lyra_monitored) && target_klass.lyra_monitored
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lyra_resolve_target_class
|
|
36
|
+
if reflection.polymorphic?
|
|
37
|
+
# For polymorphic associations, get class from the type column
|
|
38
|
+
type_column = reflection.foreign_type
|
|
39
|
+
type_name = owner.read_attribute(type_column)
|
|
40
|
+
return nil if type_name.blank?
|
|
41
|
+
type_name.safe_constantize
|
|
42
|
+
else
|
|
43
|
+
reflection.klass
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
# If class resolution fails, fall back to AR's normal loading
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lyra_find_target_from_cache
|
|
51
|
+
foreign_key_value = owner.read_attribute(reflection.foreign_key)
|
|
52
|
+
return nil if foreign_key_value.nil?
|
|
53
|
+
|
|
54
|
+
target_klass = lyra_resolve_target_class
|
|
55
|
+
return nil unless target_klass
|
|
56
|
+
|
|
57
|
+
Lyra::Projections::EventStoreReader.find(target_klass, foreign_key_value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module HasOneAssociationPatch
|
|
62
|
+
# Rails 8+ passes async: keyword argument to find_target
|
|
63
|
+
def find_target(async: false)
|
|
64
|
+
if lyra_should_use_cache?
|
|
65
|
+
lyra_find_target_from_cache
|
|
66
|
+
else
|
|
67
|
+
super
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def lyra_should_use_cache?
|
|
74
|
+
klass = reflection.klass
|
|
75
|
+
klass.respond_to?(:lyra_monitored) &&
|
|
76
|
+
klass.lyra_monitored &&
|
|
77
|
+
Lyra.event_sourcing_mode? &&
|
|
78
|
+
Lyra.config.projection_mode == :disabled
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lyra_find_target_from_cache
|
|
82
|
+
owner_key_value = owner.read_attribute(reflection.active_record_primary_key)
|
|
83
|
+
return nil if owner_key_value.nil?
|
|
84
|
+
|
|
85
|
+
Lyra::Projections::EventStoreReader.find_by(
|
|
86
|
+
reflection.klass,
|
|
87
|
+
{ reflection.foreign_key => owner_key_value }
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module HasManyAssociationPatch
|
|
93
|
+
# Rails 8+ passes async: keyword argument to find_target
|
|
94
|
+
def find_target(async: false)
|
|
95
|
+
if lyra_should_use_cache?
|
|
96
|
+
lyra_find_target_from_cache
|
|
97
|
+
else
|
|
98
|
+
super
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Override count_records to use cache
|
|
103
|
+
def count_records
|
|
104
|
+
if lyra_should_use_cache?
|
|
105
|
+
lyra_get_cached_relation.count
|
|
106
|
+
else
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Override size to use cache (used by any?, empty?, etc.)
|
|
112
|
+
def size
|
|
113
|
+
if lyra_should_use_cache?
|
|
114
|
+
if loaded?
|
|
115
|
+
target.size
|
|
116
|
+
else
|
|
117
|
+
lyra_get_cached_relation.count
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Override empty? directly
|
|
125
|
+
def empty?
|
|
126
|
+
if lyra_should_use_cache?
|
|
127
|
+
size == 0
|
|
128
|
+
else
|
|
129
|
+
super
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def lyra_should_use_cache?
|
|
136
|
+
klass = reflection.klass
|
|
137
|
+
klass.respond_to?(:lyra_monitored) &&
|
|
138
|
+
klass.lyra_monitored &&
|
|
139
|
+
Lyra.event_sourcing_mode? &&
|
|
140
|
+
Lyra.config.projection_mode == :disabled
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def lyra_get_cached_relation
|
|
144
|
+
owner_key_value = owner.read_attribute(reflection.active_record_primary_key)
|
|
145
|
+
return Lyra::Projections::CachedRelation.new(reflection.klass, []) if owner_key_value.nil?
|
|
146
|
+
|
|
147
|
+
Lyra::Projections::EventStoreReader.where(
|
|
148
|
+
reflection.klass,
|
|
149
|
+
{ reflection.foreign_key => owner_key_value }
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def lyra_find_target_from_cache
|
|
154
|
+
lyra_get_cached_relation.to_a
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.install!
|
|
159
|
+
return if @installed
|
|
160
|
+
|
|
161
|
+
ActiveRecord::Associations::BelongsToAssociation.prepend(BelongsToAssociationPatch)
|
|
162
|
+
ActiveRecord::Associations::HasOneAssociation.prepend(HasOneAssociationPatch)
|
|
163
|
+
ActiveRecord::Associations::HasManyAssociation.prepend(HasManyAssociationPatch)
|
|
164
|
+
|
|
165
|
+
@installed = true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|