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.
- checksums.yaml +7 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +210 -0
- data/LICENSE +21 -0
- data/PLAN.md +158 -0
- data/README.md +559 -0
- data/Rakefile +12 -0
- data/app/controllers/acta/web/application_controller.rb +10 -0
- data/app/controllers/acta/web/events_controller.rb +37 -0
- data/app/helpers/acta/web/application_helper.rb +106 -0
- data/app/views/acta/web/events/index.html.erb +312 -0
- data/app/views/acta/web/events/show.html.erb +72 -0
- data/app/views/layouts/acta/web/application.html.erb +594 -0
- data/config/routes.rb +4 -0
- data/lib/acta/actor.rb +34 -0
- data/lib/acta/adapters/base.rb +59 -0
- data/lib/acta/adapters/postgres.rb +73 -0
- data/lib/acta/adapters/sqlite.rb +58 -0
- data/lib/acta/adapters.rb +19 -0
- data/lib/acta/array_type.rb +30 -0
- data/lib/acta/command.rb +48 -0
- data/lib/acta/current.rb +10 -0
- data/lib/acta/errors.rb +102 -0
- data/lib/acta/event.rb +80 -0
- data/lib/acta/events_query.rb +73 -0
- data/lib/acta/handler.rb +9 -0
- data/lib/acta/model.rb +58 -0
- data/lib/acta/model_type.rb +32 -0
- data/lib/acta/projection.rb +64 -0
- data/lib/acta/projection_managed.rb +108 -0
- data/lib/acta/railtie.rb +65 -0
- data/lib/acta/reactor.rb +15 -0
- data/lib/acta/reactor_job.rb +19 -0
- data/lib/acta/record.rb +10 -0
- data/lib/acta/schema.rb +12 -0
- data/lib/acta/serializable.rb +48 -0
- data/lib/acta/testing/dsl.rb +90 -0
- data/lib/acta/testing/matchers.rb +77 -0
- data/lib/acta/testing.rb +50 -0
- data/lib/acta/types/encrypted_string.rb +63 -0
- data/lib/acta/version.rb +5 -0
- data/lib/acta/web/engine.rb +13 -0
- data/lib/acta/web/events_query.rb +81 -0
- data/lib/acta/web.rb +45 -0
- data/lib/acta.rb +296 -0
- data/lib/generators/acta/install/install_generator.rb +23 -0
- data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
- data/sig/acta.rbs +4 -0
- 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
|
data/lib/acta/railtie.rb
ADDED
|
@@ -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
|
data/lib/acta/reactor.rb
ADDED
|
@@ -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
|
data/lib/acta/record.rb
ADDED
data/lib/acta/schema.rb
ADDED
|
@@ -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
|
data/lib/acta/testing.rb
ADDED
|
@@ -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)
|
data/lib/acta/version.rb
ADDED
|
@@ -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
|