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