orfeas_lyra 0.6.0 → 0.8.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.
@@ -110,9 +110,9 @@ PamDsl.define_policy :university_system do
110
110
  purpose :communication do
111
111
  describe "Important academic and administrative communications"
112
112
  basis :legitimate_interests
113
+ lia_documented!
113
114
  requires :email
114
115
  optionally :phone, :name
115
- meta :balancing_test_performed, true
116
116
  end
117
117
 
118
118
  purpose :academic_records do
@@ -140,6 +140,7 @@ PamDsl.define_policy :university_system do
140
140
  purpose :analytics do
141
141
  describe "Improving educational services and user experience"
142
142
  basis :legitimate_interests
143
+ lia_documented!
143
144
  requires :student_id
144
145
  meta :anonymization_applied, true
145
146
  end
data/lib/lyra/engine.rb CHANGED
@@ -61,6 +61,7 @@ module Lyra
61
61
  # Load rake tasks
62
62
  rake_tasks do
63
63
  load "tasks/lyra_schema.rake"
64
+ load "tasks/lyra_projections.rake"
64
65
  end
65
66
  end
66
67
  end
@@ -8,10 +8,12 @@ module Lyra
8
8
  class_attribute :lyra_monitored, default: false
9
9
  class_attribute :lyra_config
10
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?
11
+ # Callbacks for monitoring CRUD operations (after_* = non-blocking).
12
+ # Gated on the mode as well as the model: in :disabled mode a monitored
13
+ # model must behave exactly like plain ActiveRecord and emit no events.
14
+ after_create :lyra_intercept_create, if: :lyra_events_enabled?
15
+ after_update :lyra_intercept_update, if: :lyra_events_enabled?
16
+ after_destroy :lyra_intercept_destroy, if: :lyra_events_enabled?
15
17
 
16
18
  # For hijack mode - run before operation, store event, then let save proceed
17
19
  before_create :lyra_hijack_create, if: :lyra_hijack_mode?
@@ -184,6 +186,13 @@ module Lyra
184
186
  self.class.lyra_monitored
185
187
  end
186
188
 
189
+ # Monitoring a model is not enough to emit events: :disabled mode must be a
190
+ # true ORM baseline. Hijack and event-sourcing modes still pass this guard;
191
+ # their after_* callbacks return early via @lyra_hijacked.
192
+ def lyra_events_enabled?
193
+ lyra_monitored? && !Lyra.disabled_mode?
194
+ end
195
+
187
196
  def lyra_hijack_mode?
188
197
  lyra_monitored? && Lyra.hijack_mode?
189
198
  end
@@ -30,7 +30,7 @@ module Lyra
30
30
  @pam_compliance = PamDsl::GDPRCompliance.new(
31
31
  subject_id: subject_id,
32
32
  subject_type: subject_type,
33
- event_reader: method(:read_subject_events),
33
+ record_reader: method(:read_subject_events),
34
34
  attribute_extractor: method(:extract_attributes),
35
35
  timestamp_extractor: method(:extract_timestamp),
36
36
  operation_extractor: method(:extract_operation),
@@ -37,16 +37,12 @@ module Lyra
37
37
  !@policy.nil?
38
38
  end
39
39
 
40
- # Validate data access for a purpose
41
- def validate_access!(field_names, purpose, consent_status = {})
40
+ # Validate data access for a (fields, purpose, subject) triple.
41
+ # subject: is the data subject identifier required by Policy#validate_access!
42
+ def validate_access!(field_names, purpose, subject:)
42
43
  return true unless policy_loaded?
43
44
 
44
- @policy.validate_access!(
45
- field_names,
46
- purpose,
47
- consent_granted: consent_status[:granted] || false,
48
- consent_granted_at: consent_status[:granted_at]
49
- )
45
+ @policy.validate_access!(field_names, purpose, subject: subject)
50
46
  end
51
47
 
52
48
  # Get PII fields from attributes using policy and/or detector
@@ -249,7 +249,7 @@ module Lyra
249
249
  # Load events from stream
250
250
  def load_events(stream_name)
251
251
  Lyra.config.event_store.read.stream(stream_name).to_a
252
- rescue RubyEventStore::StreamNotFound
252
+ rescue RubyEventStore::EventNotFoundInStream
253
253
  []
254
254
  end
255
255
 
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lyra
4
+ module Projections
5
+ # Rebuilds read-model tables from the event log.
6
+ #
7
+ # This is the load-bearing operation of event sourcing: because the event
8
+ # log is the source of truth and the model tables are a derived projection,
9
+ # the tables must be reconstructable from the log alone. Rebuild enumerates
10
+ # every stream for a model, clears the read model, and replays each event
11
+ # through the *same* +ModelProjection.project+ path used by live sync/async
12
+ # projection -- so a rebuilt table is byte-for-byte what live projection
13
+ # would have produced.
14
+ #
15
+ # Usage:
16
+ # Lyra::Projections::Rebuild.rebuild(Registration)
17
+ # Lyra::Projections::Rebuild.rebuild_all # all monitored models
18
+ # Lyra::Projections::Rebuild.rebuild(Order, truncate: false)
19
+ #
20
+ # Or via rake:
21
+ # rake lyra:projections:rebuild MODEL=Registration
22
+ # rake lyra:projections:rebuild # all monitored models
23
+ #
24
+ class Rebuild
25
+ class << self
26
+ # Rebuild a single model's read-model table from its event streams.
27
+ #
28
+ # @param model_class [Class] the ActiveRecord model to rebuild
29
+ # @param truncate [Boolean] clear the table before replaying (default true);
30
+ # set false to reconstruct only the streamed rows in place
31
+ # @return [Hash] statistics for the rebuild
32
+ def rebuild(model_class, truncate: true)
33
+ stats = {
34
+ model: model_class.name,
35
+ streams: 0, events: 0, records: 0, destroyed: 0
36
+ }
37
+ rebuilt_ids = []
38
+
39
+ model_class.transaction do
40
+ clear_read_model(model_class) if truncate
41
+
42
+ stream_ids_for(model_class).each do |id|
43
+ stats[:streams] += 1
44
+ rebuilt_ids << id
45
+ replayed, present = replay_stream(model_class, id)
46
+ stats[:events] += replayed
47
+ present ? stats[:records] += 1 : stats[:destroyed] += 1
48
+ end
49
+ end
50
+
51
+ invalidate_caches(model_class, rebuilt_ids)
52
+ stats
53
+ end
54
+
55
+ # Rebuild every monitored model (or an explicit list).
56
+ #
57
+ # @param models [Array<Class>] models to rebuild (defaults to all monitored)
58
+ # @param truncate [Boolean] clear each table before replaying
59
+ # @return [Array<Hash>] per-model statistics
60
+ def rebuild_all(models = Lyra.config.monitored_models, truncate: true)
61
+ models.map { |model_class| rebuild(model_class, truncate: truncate) }
62
+ end
63
+
64
+ private
65
+
66
+ # Replay a single stream (one record's lifecycle) through the live
67
+ # projector, in append order. Returns [events_replayed, still_present?].
68
+ def replay_stream(model_class, id)
69
+ stream_name = "#{model_class.name}$#{id}"
70
+ events = Lyra.config.event_store.read.stream(stream_name).to_a
71
+ replayed = 0
72
+
73
+ events.each do |event|
74
+ operation = operation_for(event)
75
+ next unless operation
76
+
77
+ ModelProjection.project(model_class, operation, build_result_from_event(event))
78
+ replayed += 1
79
+ end
80
+
81
+ present = !(events.last && events.last.event_type.to_s.end_with?("Destroyed"))
82
+ [replayed, present]
83
+ end
84
+
85
+ # Map an event type to a CRUD operation. Unknown event types are skipped.
86
+ def operation_for(event)
87
+ case event.event_type.to_s
88
+ when /Created$/ then :create
89
+ when /Updated$/ then :update
90
+ when /Destroyed$/ then :destroy
91
+ end
92
+ end
93
+
94
+ # Reconstruct the CommandResult-like struct ModelProjection expects,
95
+ # identical to Lyra::Projections::AsyncProjectionJob so rebuild and live
96
+ # async projection share one code path.
97
+ def build_result_from_event(event)
98
+ Struct.new(:events, :attributes, :success?, keyword_init: true).new(
99
+ events: [event],
100
+ attributes: event.data[:attributes] || event.data["attributes"] || {},
101
+ success?: true
102
+ )
103
+ end
104
+
105
+ # Enumerate the record ids that have an event stream for this model.
106
+ def stream_ids_for(model_class)
107
+ prefix = "#{model_class.name}$"
108
+ sql = ActiveRecord::Base.sanitize_sql_array(
109
+ ["SELECT DISTINCT stream FROM event_store_events_in_streams WHERE stream LIKE ?", "#{prefix}%"]
110
+ )
111
+ ActiveRecord::Base.connection.select_values(sql).map { |stream| stream.sub(prefix, "") }
112
+ end
113
+
114
+ # Clear the read-model table, bypassing callbacks/strict-access guards
115
+ # (this is a legitimate bulk projection operation, like ModelProjection).
116
+ def clear_read_model(model_class)
117
+ previous = Thread.current[:lyra_bypass_strict_access]
118
+ Thread.current[:lyra_bypass_strict_access] = true
119
+ begin
120
+ model_class.unscoped.delete_all
121
+ ensure
122
+ Thread.current[:lyra_bypass_strict_access] = previous
123
+ end
124
+ end
125
+
126
+ # Drop any cached (ES-NoProj) reconstructions so reads reflect the rebuild.
127
+ #
128
+ # Invalidates the specific rebuilt record ids individually (store-agnostic
129
+ # +cache_delete+) rather than a wildcard sweep -- SolidCache, the default
130
+ # backend, advertises +delete_matched+ but raises NotImplementedError.
131
+ def invalidate_caches(model_class, ids)
132
+ return unless defined?(CachedProjection)
133
+
134
+ ids.each { |id| CachedProjection.invalidate(model_class, id) }
135
+ rescue StandardError, NotImplementedError => e
136
+ Rails.logger.warn("Lyra::Projections::Rebuild: cache invalidation failed - #{e.message}") if defined?(Rails)
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
data/lib/lyra/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Lyra
2
- VERSION = "0.6.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/lyra.rb CHANGED
@@ -48,6 +48,7 @@ require "lyra/strict_data_access"
48
48
 
49
49
  # Event sourcing projections
50
50
  require "lyra/projections/model_projection"
51
+ require "lyra/projections/rebuild"
51
52
  require "lyra/projections/async_projection_job"
52
53
  require "lyra/projections/cached_projection"
53
54
  require "lyra/projections/cached_relation"
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :lyra do
4
+ namespace :projections do
5
+ desc "Rebuild read-model tables from the event log " \
6
+ "(MODEL=Namespace::Model[,Other] for specific models, or all monitored models; " \
7
+ "TRUNCATE=false to reconstruct in place)"
8
+ task rebuild: :environment do
9
+ models =
10
+ if ENV["MODEL"]
11
+ ENV["MODEL"].split(",").map { |name| name.strip.constantize }
12
+ else
13
+ Lyra.config.monitored_models
14
+ end
15
+
16
+ if models.empty?
17
+ puts "No models to rebuild."
18
+ puts "Pass MODEL=YourModel, or configure models with Lyra.config.monitor_model(YourModel)."
19
+ exit 1
20
+ end
21
+
22
+ truncate = ENV.fetch("TRUNCATE", "true") != "false"
23
+
24
+ puts "Rebuilding #{models.size} model(s) from the event log (truncate=#{truncate})..."
25
+ models.each do |model_class|
26
+ stats = Lyra::Projections::Rebuild.rebuild(model_class, truncate: truncate)
27
+ puts format(
28
+ " %-30s %5d records, %4d destroyed, %6d events across %5d streams",
29
+ stats[:model], stats[:records], stats[:destroyed], stats[:events], stats[:streams]
30
+ )
31
+ end
32
+ puts "Done."
33
+ end
34
+ end
35
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: orfeas_lyra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
@@ -173,6 +173,7 @@ files:
173
173
  - lib/lyra/projections/cached_relation.rb
174
174
  - lib/lyra/projections/event_store_reader.rb
175
175
  - lib/lyra/projections/model_projection.rb
176
+ - lib/lyra/projections/rebuild.rb
176
177
  - lib/lyra/schema/diff.rb
177
178
  - lib/lyra/schema/event_class_registrar.rb
178
179
  - lib/lyra/schema/generator.rb
@@ -186,13 +187,9 @@ files:
186
187
  - lib/lyra/visualization/activity_heatmap.rb
187
188
  - lib/lyra/visualization/event_graph.rb
188
189
  - lib/lyra/visualization/timeline.rb
189
- - lib/tasks/dist.rake
190
- - lib/tasks/gems.rake
190
+ - lib/tasks/lyra_projections.rake
191
191
  - lib/tasks/lyra_schema.rake
192
192
  - lib/tasks/lyra_workflows.rake
193
- - lib/tasks/public_release.rake
194
- - lib/tasks/stats.rake
195
- - lib/tasks/testbed.rake
196
193
  - lib/tasks/version.rake
197
194
  homepage: https://github.com/mpantel/lyra-engine
198
195
  licenses:
@@ -215,7 +212,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
215
212
  - !ruby/object:Gem::Version
216
213
  version: '0'
217
214
  requirements: []
218
- rubygems_version: 4.0.3
215
+ rubygems_version: 4.0.20
219
216
  specification_version: 4
220
217
  summary: CRUD to Event Sourcing transformation engine
221
218
  test_files: []