acta 0.2.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +210 -0
  4. data/LICENSE +21 -0
  5. data/PLAN.md +158 -0
  6. data/README.md +559 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/acta/web/application_controller.rb +10 -0
  9. data/app/controllers/acta/web/events_controller.rb +37 -0
  10. data/app/helpers/acta/web/application_helper.rb +106 -0
  11. data/app/views/acta/web/events/index.html.erb +312 -0
  12. data/app/views/acta/web/events/show.html.erb +72 -0
  13. data/app/views/layouts/acta/web/application.html.erb +594 -0
  14. data/config/routes.rb +4 -0
  15. data/lib/acta/actor.rb +34 -0
  16. data/lib/acta/adapters/base.rb +59 -0
  17. data/lib/acta/adapters/postgres.rb +73 -0
  18. data/lib/acta/adapters/sqlite.rb +58 -0
  19. data/lib/acta/adapters.rb +19 -0
  20. data/lib/acta/array_type.rb +30 -0
  21. data/lib/acta/command.rb +48 -0
  22. data/lib/acta/current.rb +10 -0
  23. data/lib/acta/errors.rb +102 -0
  24. data/lib/acta/event.rb +80 -0
  25. data/lib/acta/events_query.rb +73 -0
  26. data/lib/acta/handler.rb +9 -0
  27. data/lib/acta/model.rb +58 -0
  28. data/lib/acta/model_type.rb +32 -0
  29. data/lib/acta/projection.rb +64 -0
  30. data/lib/acta/projection_managed.rb +108 -0
  31. data/lib/acta/railtie.rb +65 -0
  32. data/lib/acta/reactor.rb +15 -0
  33. data/lib/acta/reactor_job.rb +19 -0
  34. data/lib/acta/record.rb +10 -0
  35. data/lib/acta/schema.rb +12 -0
  36. data/lib/acta/serializable.rb +48 -0
  37. data/lib/acta/testing/dsl.rb +90 -0
  38. data/lib/acta/testing/matchers.rb +77 -0
  39. data/lib/acta/testing.rb +50 -0
  40. data/lib/acta/types/encrypted_string.rb +63 -0
  41. data/lib/acta/version.rb +5 -0
  42. data/lib/acta/web/engine.rb +13 -0
  43. data/lib/acta/web/events_query.rb +81 -0
  44. data/lib/acta/web.rb +45 -0
  45. data/lib/acta.rb +296 -0
  46. data/lib/generators/acta/install/install_generator.rb +23 -0
  47. data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
  48. data/sig/acta.rbs +4 -0
  49. metadata +152 -0
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Acta
6
+ # Marks an ActiveRecord model as projection-managed: its rows are
7
+ # maintained by an Acta::Projection from the event log, so writes from
8
+ # anywhere else (controllers, console, rake tasks, callbacks on other
9
+ # models) bypass the log and break Acta.rebuild!'s determinism.
10
+ #
11
+ # Opt in with `acta_managed!` on the AR class:
12
+ #
13
+ # class Trail < ApplicationRecord
14
+ # acta_managed! # raise on out-of-band writes
15
+ # end
16
+ #
17
+ # class TrailAlias < ApplicationRecord
18
+ # acta_managed! on_violation: :warn # warn instead, for incremental migration
19
+ # end
20
+ #
21
+ # Inside an `Acta::Projection` `on EventClass do |e| ... end` block (or
22
+ # during `Acta.rebuild!`'s truncate phase), `Acta::Projection.applying?`
23
+ # is true and writes are allowed. Outside, they raise
24
+ # `Acta::ProjectionWriteError` (or warn if so configured).
25
+ #
26
+ # Tests, migrations, and one-off backfills can wrap intentional
27
+ # out-of-band writes in `Acta::Projection.applying! { ... }` to bypass
28
+ # the safety net explicitly.
29
+ module ProjectionManaged
30
+ extend ActiveSupport::Concern
31
+
32
+ GUARDED_CLASS_METHODS = %i[
33
+ update_all
34
+ delete_all
35
+ insert
36
+ insert!
37
+ insert_all
38
+ insert_all!
39
+ upsert
40
+ upsert_all
41
+ ].freeze
42
+
43
+ GUARDED_INSTANCE_METHODS = %i[
44
+ update_columns
45
+ update_column
46
+ ].freeze
47
+
48
+ VALID_VIOLATION_ACTIONS = %i[ raise warn ].freeze
49
+
50
+ class_methods do
51
+ def acta_managed!(on_violation: :raise)
52
+ unless VALID_VIOLATION_ACTIONS.include?(on_violation)
53
+ raise ArgumentError,
54
+ "acta_managed! on_violation must be one of #{VALID_VIOLATION_ACTIONS.inspect}, got #{on_violation.inspect}"
55
+ end
56
+
57
+ @acta_on_violation = on_violation
58
+
59
+ before_save :_acta_assert_projection_applying!
60
+ before_destroy :_acta_assert_projection_applying!
61
+
62
+ singleton_class.prepend(ClassWriteGuards)
63
+ end
64
+
65
+ def acta_managed?
66
+ !@acta_on_violation.nil?
67
+ end
68
+
69
+ def acta_on_violation
70
+ @acta_on_violation
71
+ end
72
+ end
73
+
74
+ module ClassWriteGuards
75
+ ProjectionManaged::GUARDED_CLASS_METHODS.each do |method|
76
+ define_method(method) do |*args, **kwargs, &block|
77
+ ProjectionManaged.assert_projection_applying!(self, method)
78
+ super(*args, **kwargs, &block)
79
+ end
80
+ end
81
+ end
82
+
83
+ GUARDED_INSTANCE_METHODS.each do |method|
84
+ define_method(method) do |*args, **kwargs, &block|
85
+ ProjectionManaged.assert_projection_applying!(self.class, method)
86
+ super(*args, **kwargs, &block)
87
+ end
88
+ end
89
+
90
+ def self.assert_projection_applying!(model_class, write_method)
91
+ return if Acta::Projection.applying?
92
+
93
+ action = model_class.acta_on_violation
94
+ case action
95
+ when :raise
96
+ raise Acta::ProjectionWriteError.new(model_class:, write_method:)
97
+ when :warn
98
+ warn "[acta] #{Acta::ProjectionWriteError.new(model_class:, write_method:).message}"
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def _acta_assert_projection_applying!
105
+ ProjectionManaged.assert_projection_applying!(self.class, :save)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Acta
6
+ # Forces projection / handler / reactor classes to load at boot, so they can
7
+ # register themselves with Acta before the first event is emitted.
8
+ #
9
+ # Without this, Zeitwerk lazy-loads them on first reference. A projection that
10
+ # nothing has touched yet is silently unsubscribed — emits succeed, the event
11
+ # row is written, but the projection never runs and the read model goes stale.
12
+ # No error, no warning. See https://github.com/whoojemaflip/acta/issues/7.
13
+ #
14
+ # Configurable via `config.acta.{projection,handler,reactor}_paths` if your app
15
+ # puts subscribers somewhere other than the conventional `app/projections`,
16
+ # `app/handlers`, `app/reactors`. Set a path list to `[]` to opt out.
17
+ class Railtie < ::Rails::Railtie
18
+ DEFAULT_PROJECTION_PATHS = %w[app/projections].freeze
19
+ DEFAULT_HANDLER_PATHS = %w[app/handlers].freeze
20
+ DEFAULT_REACTOR_PATHS = %w[app/reactors].freeze
21
+
22
+ config.acta = ActiveSupport::OrderedOptions.new
23
+
24
+ initializer "acta.subscriber_path_defaults" do |app|
25
+ cfg = app.config.acta
26
+ cfg.projection_paths = DEFAULT_PROJECTION_PATHS.dup if cfg.projection_paths.nil?
27
+ cfg.handler_paths = DEFAULT_HANDLER_PATHS.dup if cfg.handler_paths.nil?
28
+ cfg.reactor_paths = DEFAULT_REACTOR_PATHS.dup if cfg.reactor_paths.nil?
29
+ end
30
+
31
+ initializer "acta.eager_load_subscribers" do |app|
32
+ app.config.to_prepare do
33
+ Acta::Railtie.eager_load_subscribers!(app)
34
+ end
35
+ end
36
+
37
+ def self.eager_load_subscribers!(app)
38
+ subscriber_paths(app).each { |path| eager_load_path(path) }
39
+ end
40
+
41
+ def self.subscriber_paths(app)
42
+ cfg = app.config.acta
43
+ relative = [ *cfg.projection_paths, *cfg.handler_paths, *cfg.reactor_paths ].compact.uniq
44
+ relative.map { |path| app.root.join(path).to_s }.select { |path| Dir.exist?(path) }
45
+ end
46
+
47
+ def self.eager_load_path(path)
48
+ if rails_zeitwerk_loader_for(path)&.respond_to?(:eager_load_dir)
49
+ rails_zeitwerk_loader_for(path).eager_load_dir(path)
50
+ else
51
+ Dir.glob(File.join(path, "**/*.rb")).sort.each { |file| require file }
52
+ end
53
+ end
54
+
55
+ def self.rails_zeitwerk_loader_for(path)
56
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:autoloaders)
57
+
58
+ autoloaders = ::Rails.autoloaders
59
+ [ autoloaders.main, autoloaders.once ].compact.find do |loader|
60
+ loader.respond_to?(:dirs) && loader.dirs.any? { |dir| path == dir || path.start_with?("#{dir}/") }
61
+ end
62
+ end
63
+ private_class_method :rails_zeitwerk_loader_for
64
+ end
65
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ class Reactor < Handler
5
+ class << self
6
+ def sync!
7
+ @sync = true
8
+ end
9
+
10
+ def sync?
11
+ @sync == true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Acta
6
+ class ReactorJob < ActiveJob::Base
7
+ def perform(event_uuid:, reactor_class:, event_class:)
8
+ event = Acta.events.find_by_uuid(event_uuid)
9
+ return unless event
10
+
11
+ reactor = Object.const_get(reactor_class)
12
+ ev_class = Object.const_get(event_class)
13
+
14
+ Acta.handlers[ev_class]
15
+ .select { |r| r[:handler_class] == reactor && r[:kind] == :reactor }
16
+ .each { |r| r[:block].call(event) }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Acta
6
+ class Record < ActiveRecord::Base
7
+ self.table_name = "events"
8
+ self.inheritance_column = nil
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ module Schema
5
+ TABLE_NAME = :events
6
+
7
+ def self.install(connection, table_name: TABLE_NAME)
8
+ adapter = Acta::Adapters.for(connection)
9
+ adapter.install_schema(connection, table_name:)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Acta
6
+ module Serializable
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def acta_serialize(except: nil, only: nil)
11
+ raise ArgumentError, "acta_serialize: pass only one of except: or only:" if except && only
12
+
13
+ @acta_serialize_options = {
14
+ except: except&.map(&:to_s),
15
+ only: only&.map(&:to_s)
16
+ }
17
+ end
18
+
19
+ def acta_serialize_options
20
+ @acta_serialize_options ||= { except: nil, only: nil }
21
+ end
22
+
23
+ def from_acta_hash(hash)
24
+ return nil if hash.nil?
25
+
26
+ known = column_names
27
+ filtered = hash.each_with_object({}) do |(key, value), acc|
28
+ name = key.to_s
29
+ acc[name.to_sym] = value if known.include?(name)
30
+ end
31
+ new(**filtered)
32
+ end
33
+ end
34
+
35
+ def to_acta_hash
36
+ opts = self.class.acta_serialize_options
37
+ hash = attributes
38
+
39
+ if opts[:only]
40
+ hash.slice(*opts[:only])
41
+ elsif opts[:except]
42
+ hash.except(*opts[:except])
43
+ else
44
+ hash
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ module Testing
5
+ module DSL
6
+ # Seed prior events. Anything emitted inside the block is treated as
7
+ # pre-existing history; the baseline is set after the block runs so
8
+ # subsequent assertions only see events from the 'when' phase.
9
+ def given_events(&block)
10
+ block.call if block
11
+ @_acta_baseline_count = Acta::Record.count
12
+ end
13
+
14
+ # Invoke a command instance and capture what it emitted.
15
+ def when_command(command)
16
+ @_acta_baseline_count ||= Acta::Record.count
17
+ command.call
18
+ @_acta_emitted_events = Acta.events.all.drop(@_acta_baseline_count)
19
+ @_acta_matched_events = []
20
+ end
21
+
22
+ # Evaluate a block that emits events, capturing them for assertions.
23
+ def when_event(&block)
24
+ @_acta_baseline_count ||= Acta::Record.count
25
+ block.call
26
+ @_acta_emitted_events = Acta.events.all.drop(@_acta_baseline_count)
27
+ @_acta_matched_events = []
28
+ end
29
+
30
+ # Assert that at least one of the captured events matches the class
31
+ # and attributes. Marks the matched event so `then_emitted_nothing_else`
32
+ # can verify the remainder.
33
+ def then_emitted(event_class, **attributes)
34
+ @_acta_matched_events ||= []
35
+ event = @_acta_emitted_events.find do |e|
36
+ e.is_a?(event_class) &&
37
+ attributes.all? { |k, v| e.public_send(k) == v } &&
38
+ !@_acta_matched_events.include?(e)
39
+ end
40
+
41
+ emitted_classes = @_acta_emitted_events.map(&:class).inspect
42
+ expect(event).not_to(
43
+ be_nil,
44
+ "expected emission of #{event_class} matching #{attributes.inspect}, but emitted: #{emitted_classes}"
45
+ )
46
+
47
+ @_acta_matched_events << event
48
+ end
49
+
50
+ # Assert no emissions remain unmatched.
51
+ def then_emitted_nothing_else
52
+ @_acta_matched_events ||= []
53
+ remaining = @_acta_emitted_events - @_acta_matched_events
54
+ expect(remaining).to(
55
+ be_empty,
56
+ "expected no further emissions, but also emitted: #{remaining.map(&:class).inspect}"
57
+ )
58
+ end
59
+
60
+ # Run the block with a different Acta::Current.actor, restoring the
61
+ # previous actor afterward (even if the block raises). Useful for
62
+ # asserting an emit attributes the right user when the surrounding
63
+ # spec's default actor would otherwise overwrite it.
64
+ def with_actor(**attributes)
65
+ previous = Acta::Current.actor
66
+ Acta::Current.actor = Acta::Actor.new(**attributes)
67
+ yield
68
+ ensure
69
+ Acta::Current.actor = previous
70
+ end
71
+
72
+ # Assert that running Acta.rebuild! twice produces the same projected
73
+ # state. The block returns a snapshot of the relevant state (whatever
74
+ # the app considers authoritative for this projection).
75
+ def ensure_replay_deterministic(&snapshot)
76
+ Acta.rebuild!
77
+ first = snapshot.call
78
+ Acta.rebuild!
79
+ second = snapshot.call
80
+
81
+ expect(second).to(
82
+ eq(first),
83
+ "replay is not deterministic\n" \
84
+ "first pass: #{first.inspect}\n" \
85
+ "second pass: #{second.inspect}"
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ RSpec::Matchers.define :emit do |event_class|
6
+ supports_block_expectations
7
+
8
+ match do |block|
9
+ before_count = Acta::Record.count
10
+ block.call
11
+ @actual_events = Acta.events.all.drop(before_count)
12
+
13
+ matching = @actual_events.select { |event| event.is_a?(event_class) }
14
+
15
+ if @expected_attributes
16
+ @matching_event = matching.find do |event|
17
+ @expected_attributes.all? { |k, v| event.public_send(k) == v }
18
+ end
19
+ !@matching_event.nil?
20
+ else
21
+ !matching.empty?
22
+ end
23
+ end
24
+
25
+ chain :with do |attributes|
26
+ @expected_attributes = attributes
27
+ end
28
+
29
+ failure_message do
30
+ detail = if @expected_attributes
31
+ "#{event_class} with attributes #{@expected_attributes.inspect}"
32
+ else
33
+ event_class.to_s
34
+ end
35
+ "expected block to emit #{detail}, but emitted: #{@actual_events.map(&:class).inspect}"
36
+ end
37
+
38
+ failure_message_when_negated do
39
+ "expected block not to emit #{event_class}, but emitted: #{@actual_events.map(&:class).inspect}"
40
+ end
41
+ end
42
+
43
+ RSpec::Matchers.define :emit_events do |expected_classes|
44
+ supports_block_expectations
45
+
46
+ match do |block|
47
+ before_count = Acta::Record.count
48
+ block.call
49
+ @actual_events = Acta.events.all.drop(before_count)
50
+ @actual_classes = @actual_events.map(&:class)
51
+
52
+ @actual_classes == expected_classes
53
+ end
54
+
55
+ failure_message do
56
+ "expected block to emit events #{expected_classes.inspect} in order, but emitted: #{@actual_classes.inspect}"
57
+ end
58
+ end
59
+
60
+ RSpec::Matchers.define :emit_any_events do
61
+ supports_block_expectations
62
+
63
+ match do |block|
64
+ before_count = Acta::Record.count
65
+ block.call
66
+ @actual_events = Acta.events.all.drop(before_count)
67
+ !@actual_events.empty?
68
+ end
69
+
70
+ failure_message do
71
+ "expected block to emit events, but emitted none"
72
+ end
73
+
74
+ failure_message_when_negated do
75
+ "expected block not to emit events, but emitted: #{@actual_events.map(&:class).inspect}"
76
+ end
77
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Acta
6
+ module Testing
7
+ DEFAULT_ACTOR_ATTRIBUTES = { type: "system", id: "rspec", source: "test" }.freeze
8
+
9
+ module_function
10
+
11
+ # Runs the given block with ActiveJob's :inline adapter, so async
12
+ # reactors run synchronously in the caller's thread. Restores the
13
+ # original adapter when the block returns (or raises).
14
+ def test_mode
15
+ original = ActiveJob::Base.queue_adapter
16
+ ActiveJob::Base.queue_adapter = :inline
17
+ yield
18
+ ensure
19
+ ActiveJob::Base.queue_adapter = original
20
+ end
21
+
22
+ # Configures RSpec to set Acta::Current.actor before every example, so
23
+ # specs that emit (directly or via a command) don't trip Acta::MissingActor.
24
+ # Resets Acta::Current after each example so state doesn't leak.
25
+ #
26
+ # # spec/rails_helper.rb
27
+ # require "acta/testing"
28
+ # RSpec.configure do |config|
29
+ # Acta::Testing.default_actor!(config)
30
+ # end
31
+ #
32
+ # Override the default actor's attributes per project:
33
+ #
34
+ # Acta::Testing.default_actor!(config, type: "user", id: "test-user-1", source: "spec")
35
+ #
36
+ # Individual specs can still override Acta::Current.actor inline (or
37
+ # use Acta::Testing::DSL#with_actor for a scoped override).
38
+ def default_actor!(config, **attributes)
39
+ attrs = DEFAULT_ACTOR_ATTRIBUTES.merge(attributes)
40
+
41
+ config.before(:each) do
42
+ Acta::Current.actor = Acta::Actor.new(**attrs)
43
+ end
44
+
45
+ config.after(:each) do
46
+ Acta::Current.reset
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+ require "zlib"
5
+ require "active_record/encryption"
6
+
7
+ module Acta
8
+ module Types
9
+ # Per-attribute opt-in encryption for event payloads. Declare with
10
+ # `attribute :token, :encrypted_string` (or pass an instance directly).
11
+ #
12
+ # Encryption uses Rails' built-in ActiveRecord::Encryption — the same
13
+ # primary/deterministic/derivation keys configured for AR-encrypted
14
+ # columns. Configure once via `bin/rails db:encryption:init` and
15
+ # Rails credentials; key rotation works the same way (append a new
16
+ # primary, keep old keys for decryption).
17
+ #
18
+ # In-memory values are always plaintext: `event.token` returns the
19
+ # raw secret. The encrypted form only appears in the serialized
20
+ # payload that's written to the events table.
21
+ class EncryptedString < ActiveModel::Type::Value
22
+ def initialize(deterministic: false)
23
+ super()
24
+ @deterministic = deterministic
25
+ end
26
+
27
+ def cast(value)
28
+ return nil if value.nil?
29
+
30
+ value.to_s
31
+ end
32
+
33
+ def serialize(value)
34
+ return nil if value.nil?
35
+
36
+ encryptor.encrypt(value.to_s, **encrypt_options)
37
+ end
38
+
39
+ def deserialize(value)
40
+ return nil if value.nil?
41
+
42
+ str = value.to_s
43
+ return str unless encryptor.encrypted?(str)
44
+
45
+ encryptor.decrypt(str)
46
+ end
47
+
48
+ private
49
+
50
+ def encryptor
51
+ ActiveRecord::Encryption.encryptor
52
+ end
53
+
54
+ def encrypt_options
55
+ return {} unless @deterministic
56
+
57
+ { key_provider: ActiveRecord::Encryption.deterministic_key_provider }
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ ActiveModel::Type.register(:encrypted_string, Acta::Types::EncryptedString)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "action_dispatch" # isolate_namespace references ActionDispatch::Routing
5
+
6
+ module Acta
7
+ module Web
8
+ class Engine < ::Rails::Engine
9
+ engine_name "acta_web"
10
+ isolate_namespace Acta::Web
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Acta
4
+ module Web
5
+ # Builds the filtered Acta::Record scope that drives the event log
6
+ # admin UI. Extracted from EventsController so it can be unit-tested
7
+ # without a Rails-app fixture, and reused if other admin surfaces want
8
+ # the same filter semantics.
9
+ #
10
+ # All filter values are user-supplied. String LIKE values are passed
11
+ # through ActiveRecord::Base.sanitize_sql_like to neutralise %/_
12
+ # wildcards in user input.
13
+ class EventsQuery
14
+ # Names of params accepted; used by callers to build current-filter
15
+ # views and by tests to check the param surface.
16
+ FILTER_KEYS = %i[event_type stream_type actor_id stream_key q].freeze
17
+
18
+ def initialize(params = {})
19
+ @event_type = presence(params[:event_type])
20
+ @stream_type = presence(params[:stream_type])
21
+ @actor_id = presence(params[:actor_id])
22
+ @stream_key = presence(params[:stream_key])
23
+ @q = presence(params[:q])
24
+ end
25
+
26
+ # Returns an unloaded ActiveRecord::Relation over Acta::Record with
27
+ # the configured filters applied. Caller is responsible for ordering,
28
+ # offset, limit, and any further scope chaining.
29
+ def scope
30
+ scope = Acta::Record.all
31
+ scope = scope.where(event_type: @event_type) if @event_type
32
+ scope = scope.where(stream_type: @stream_type) if @stream_type
33
+ scope = scope.where(actor_id: @actor_id) if @actor_id
34
+ scope = apply_stream_key(scope) if @stream_key
35
+ scope = apply_q(scope) if @q
36
+ scope
37
+ end
38
+
39
+ # Returns only the filters that are actually present, with symbol keys.
40
+ # Useful to build filter-chip UIs.
41
+ def active_filters
42
+ {
43
+ event_type: @event_type,
44
+ stream_type: @stream_type,
45
+ actor_id: @actor_id,
46
+ stream_key: @stream_key,
47
+ q: @q
48
+ }.compact
49
+ end
50
+
51
+ private
52
+
53
+ def presence(value)
54
+ return nil if value.nil?
55
+
56
+ s = value.to_s
57
+ s.empty? ? nil : s
58
+ end
59
+
60
+ # ActiveRecord::Base.sanitize_sql_like escapes %, _, and \ by prefixing
61
+ # with \. The LIKE clause must declare ESCAPE '\\' for those escapes to
62
+ # take effect — otherwise the sanitized backslash is treated as a
63
+ # literal character and user-supplied wildcards continue to match.
64
+ LIKE_ESCAPE = "\\".freeze
65
+
66
+ def apply_stream_key(scope)
67
+ sanitized = ActiveRecord::Base.sanitize_sql_like(@stream_key)
68
+ scope.where("stream_key LIKE ? ESCAPE ?", "%#{sanitized}%", LIKE_ESCAPE)
69
+ end
70
+
71
+ def apply_q(scope)
72
+ sanitized = ActiveRecord::Base.sanitize_sql_like(@q)
73
+ like = "%#{sanitized}%"
74
+ scope.where(
75
+ "(event_type LIKE :q ESCAPE :e OR stream_type LIKE :q ESCAPE :e OR stream_key LIKE :q ESCAPE :e OR actor_id LIKE :q ESCAPE :e OR source LIKE :q ESCAPE :e)",
76
+ q: like, e: LIKE_ESCAPE,
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end