acta 0.2.0 → 0.3.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.
@@ -0,0 +1,258 @@
1
+ # Event-driven pub/sub
2
+
3
+ The simplest useful shape for an Acta app: one domain event, multiple
4
+ independent subscribers, no event sourcing. The event is the
5
+ publication; reactors are the subscribers; the events table is your
6
+ audit log for free.
7
+
8
+ ## The scenario
9
+
10
+ A user signs up. As a result, several independent things should
11
+ happen:
12
+
13
+ - A welcome email is sent.
14
+ - An analytics service is pinged.
15
+ - The signup is recorded in an audit log.
16
+
17
+ These concerns have different owners, change at different rates, and
18
+ fail in different ways. Coupling them in the controller (or worse, in
19
+ an `after_create_commit` callback on the `User` model) means every
20
+ new concern requires editing the same file, and a flaky third-party
21
+ analytics call can roll back the user creation.
22
+
23
+ With Acta:
24
+
25
+ ```ruby
26
+ # app/events/user_signed_up.rb
27
+ class UserSignedUp < Acta::Event
28
+ stream :user, key: :user_id
29
+
30
+ attribute :user_id, :string
31
+ attribute :email, :string
32
+ attribute :referral_code, :string
33
+
34
+ validates :user_id, :email, presence: true
35
+ end
36
+ ```
37
+
38
+ The signup path creates the AR record and emits the event in the
39
+ same transaction:
40
+
41
+ ```ruby
42
+ # app/controllers/registrations_controller.rb
43
+ class RegistrationsController < ApplicationController
44
+ def create
45
+ ApplicationRecord.transaction do
46
+ user = User.create!(user_params)
47
+
48
+ Acta.emit(UserSignedUp.new(
49
+ user_id: user.id,
50
+ email: user.email,
51
+ referral_code: params[:referral_code]
52
+ ))
53
+ end
54
+
55
+ redirect_to dashboard_path
56
+ end
57
+ end
58
+ ```
59
+
60
+ The explicit `transaction` block is the load-bearing detail. `Acta.emit`
61
+ opens its own inner transaction (with `requires_new: true`), which
62
+ becomes a savepoint inside the outer one — so either the user row
63
+ *and* the event row commit together, or neither does. Without the
64
+ outer transaction these would be two independent commits, and a
65
+ process crash or event validation error between them would leave
66
+ you with a user who has no audit trail, no welcome email, and no
67
+ analytics ping.
68
+
69
+ That's it for the publisher. Each subscriber lives in its own file,
70
+ declares what it cares about, and ignores everything else.
71
+
72
+ ## Subscribers
73
+
74
+ ```ruby
75
+ # app/reactors/welcome_email_reactor.rb
76
+ class WelcomeEmailReactor < Acta::Reactor
77
+ on UserSignedUp do |event|
78
+ UserMailer.welcome(event.user_id).deliver_later
79
+ end
80
+ end
81
+ ```
82
+
83
+ ```ruby
84
+ # app/reactors/analytics_reactor.rb
85
+ class AnalyticsReactor < Acta::Reactor
86
+ on UserSignedUp do |event|
87
+ AnalyticsClient.track(
88
+ user_id: event.user_id,
89
+ event: "signup",
90
+ props: { referral_code: event.referral_code }
91
+ )
92
+ end
93
+ end
94
+ ```
95
+
96
+ The audit log subscriber doesn't exist as code — Acta writes every
97
+ emitted event to the `events` table by default. Browse it at `/acta`
98
+ (see the [Acta::Web engine][acta-web]) or query directly via
99
+ `Acta.events`.
100
+
101
+ [acta-web]: ../README.md#acta-web
102
+
103
+ ## What just happened
104
+
105
+ Each reactor runs **after** the database commit that wrote the event,
106
+ **asynchronously** by default (via ActiveJob). So:
107
+
108
+ - Because the controller wraps both writes in
109
+ `ApplicationRecord.transaction`, the user row and the event row
110
+ commit together. If either raises, neither is persisted — no
111
+ welcome email to a user that doesn't exist.
112
+ - Each reactor enqueues its own job. The welcome email and the
113
+ analytics ping run in parallel, isolated from each other.
114
+ - A failing analytics call doesn't roll back the signup, doesn't
115
+ block the email, doesn't surface to the user. ActiveJob's retry
116
+ semantics apply per-reactor.
117
+ - New subscribers are additive. To send a referral credit when a
118
+ signup uses a code, write a third reactor — no change to the
119
+ controller, the event, or the existing reactors.
120
+
121
+ ### A subtle caveat about reactor enqueue timing
122
+
123
+ Reactors are dispatched after Acta's inner savepoint releases but
124
+ *before* the outer transaction commits. Whether that opens a
125
+ "reactor fired but the user write rolled back" window depends on
126
+ your ActiveJob queue adapter:
127
+
128
+ - **DB-backed queues** (Solid Queue, GoodJob, Que) — the enqueue is
129
+ a row insert that participates in the outer transaction. A
130
+ rollback un-enqueues the job. Atomic.
131
+ - **Redis-backed queues** (Sidekiq) — the enqueue hits Redis
132
+ immediately and survives a rollback. Small window where the email
133
+ goes out but the user doesn't exist. Rails 7.2+ exposes
134
+ `enqueue_after_transaction_commit` to opt into deferred enqueue,
135
+ which closes the window.
136
+ - **Sync reactors** (`sync!`) — run inline during dispatch. Side
137
+ effects (email sent, third-party API called) happen before the
138
+ outer commits and can't be undone by a rollback. Reach for
139
+ `sync!` only when the side effect is itself a DB write inside the
140
+ same transaction, or when "fired but rolled back" is acceptable.
141
+
142
+ On the Rails 8.x + Solid Queue default stack, the right behaviour
143
+ falls out without extra configuration.
144
+
145
+ ## Synchronous when you need it
146
+
147
+ For tests and the rare side effect that must happen inside the same
148
+ request, opt a reactor into sync mode:
149
+
150
+ ```ruby
151
+ class CreateBillingAccountReactor < Acta::Reactor
152
+ sync!
153
+
154
+ on UserSignedUp do |event|
155
+ BillingAccount.create!(user_id: event.user_id, plan: "free")
156
+ end
157
+ end
158
+ ```
159
+
160
+ Sync reactors run **after-commit but in the caller's thread**. They
161
+ still don't block the DB transaction (so they can't roll the signup
162
+ back), but they do block the response. Reach for this when the
163
+ follow-up state must exist before the next user action — and only
164
+ then.
165
+
166
+ ## Testing
167
+
168
+ Reactor tests usually just want to assert that a side effect was
169
+ triggered. Use the matchers:
170
+
171
+ ```ruby
172
+ require "acta/testing"
173
+ require "acta/testing/matchers"
174
+
175
+ RSpec.describe RegistrationsController do
176
+ it "publishes UserSignedUp on successful signup" do
177
+ expect {
178
+ post :create, params: { user: { email: "alice@example.com" } }
179
+ }.to emit(UserSignedUp).with(email: "alice@example.com")
180
+ end
181
+ end
182
+ ```
183
+
184
+ For the reactor itself, run it inline so the side effect actually
185
+ fires:
186
+
187
+ ```ruby
188
+ RSpec.describe WelcomeEmailReactor do
189
+ it "sends the welcome email" do
190
+ Acta::Testing.test_mode do
191
+ Acta.emit(UserSignedUp.new(user_id: "u_1", email: "alice@example.com"))
192
+ end
193
+
194
+ expect(UserMailer.deliveries.last.to).to eq([ "alice@example.com" ])
195
+ end
196
+ end
197
+ ```
198
+
199
+ `Acta::Testing.test_mode` runs reactors inline for the duration of
200
+ the block, regardless of the `sync!` declaration on the class. It
201
+ keeps reactor tests synchronous without committing the whole reactor
202
+ to sync mode in production.
203
+
204
+ ## When this isn't the right shape
205
+
206
+ This pattern works when the AR records (`User`, `BillingAccount`) are
207
+ the source of truth and the events are notifications about state
208
+ changes happening elsewhere. It does **not** make the event log the
209
+ authoritative source of state — `User.create!` happens before any
210
+ event is emitted, and dropping the events table doesn't recreate
211
+ users on the next `Acta.rebuild!`.
212
+
213
+ When you want the events to *be* the source of truth — when
214
+ `Acta.rebuild!` should reproduce the projected state from the log
215
+ alone — reach for projections instead. See the [event sourcing][es]
216
+ pattern.
217
+
218
+ [es]: ../README.md#4-project-state-event-sourced
219
+
220
+ ## Compared to the alternatives
221
+
222
+ | | AR callbacks | `ActiveSupport::Notifications` | [Wisper][wisper] | Acta event-driven |
223
+ |---|---|---|---|---|
224
+ | Persistence | None | None | None | Yes — full payload, actor, timestamps |
225
+ | Async by default | No (in tx) | No (in caller) | No (in caller); async via wisper-sidekiq | Yes (ActiveJob) |
226
+ | Failure isolation | No (rolls back tx) | Sometimes | Subscriber errors propagate to publisher | Yes (per-reactor jobs) |
227
+ | Replay-able | No | No | No | Yes (the events are still there) |
228
+ | Payload typing | AR attributes | Untyped hash | Untyped args | ActiveModel-typed attributes with validations |
229
+ | Subscriber discovery | Reading the model file | Grep the codebase for `subscribe` | Subscriber registration code | `app/reactors/` directory |
230
+ | Test ergonomics | Stubs all the way down | Subscribe a block in spec | wisper-rspec matchers | Built-in matchers + `test_mode` |
231
+
232
+ [wisper]: https://github.com/krisleech/wisper
233
+
234
+ `ActiveSupport::Notifications` is the in-process, ephemeral cousin —
235
+ fire-and-forget, no persistence, ideal for instrumentation (metrics,
236
+ traces, logs) but a poor fit for domain events that other parts of
237
+ the system need to react to.
238
+
239
+ **Wisper** is the long-standing prior art for Rails domain pub/sub —
240
+ publish a symbol-named event, subscribers register interest, the
241
+ gem dispatches. It's at v3.0.0 (May 2024) with light ongoing
242
+ maintenance; not abandoned, not actively developed either. Reach for
243
+ Wisper when you want to decouple callbacks without buying into
244
+ event sourcing or a persistent log: subscriptions are dynamic,
245
+ events are untyped (any args you want), and the runtime is
246
+ process-local. Acta differs in three load-bearing ways: events are
247
+ **typed classes** with validated payload schemas (so a typo in a
248
+ field name is a class-load error, not a runtime nil); subscribers
249
+ are **after-commit + async** by default (so a flaky external API
250
+ call doesn't roll back the publisher's transaction); and every
251
+ publication is **persisted** in the events table (so you have an
252
+ audit log, can replay history, and can survive a process restart
253
+ mid-flight on a notification).
254
+
255
+ The honest summary: AS::Notifications for instrumentation, Wisper
256
+ for lightweight in-process pub/sub without persistence, Acta when
257
+ the publication itself needs to be durable and the subscribers are
258
+ fan-out side effects you want isolated from the request path.
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 7.2.0"
6
+ gem "activemodel", "~> 7.2.0"
7
+ gem "activerecord", "~> 7.2.0"
8
+ gem "activesupport", "~> 7.2.0"
9
+ gem "railties", "~> 7.2.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 8.0.0"
6
+ gem "activemodel", "~> 8.0.0"
7
+ gem "activerecord", "~> 8.0.0"
8
+ gem "activesupport", "~> 8.0.0"
9
+ gem "railties", "~> 8.0.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activejob", "~> 8.1.0"
6
+ gem "activemodel", "~> 8.1.0"
7
+ gem "activerecord", "~> 8.1.0"
8
+ gem "activesupport", "~> 8.1.0"
9
+ gem "railties", "~> 8.1.0"
10
+
11
+ group :development, :test do
12
+ gem "irb"
13
+ gem "pg", "~> 1.5"
14
+ gem "rake", "~> 13.0"
15
+ gem "rspec", "~> 3.13"
16
+ gem "rubocop-rails-omakase", require: false
17
+ gem "sqlite3", "~> 2.0"
18
+ end
19
+
20
+ gemspec path: ".."
data/lib/acta/model.rb CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  require "active_model"
4
4
  require "active_model/attributes"
5
- require_relative "model_type"
6
- require_relative "array_type"
5
+ require_relative "types/model"
6
+ require_relative "types/array"
7
7
 
8
8
  module Acta
9
9
  class Model
@@ -11,15 +11,15 @@ module Acta
11
11
  include ActiveModel::Attributes
12
12
 
13
13
  # Accept:
14
- # - a class as a type (Acta::Model / Acta::Serializable) — wrapped in ModelType
15
- # - array_of: Class or array_of: :symbol — wrapped in ArrayType
14
+ # - a class as a type (Acta::Model / Acta::Serializable) — wrapped in Acta::Types::Model
15
+ # - array_of: Class or array_of: :symbol — wrapped in Acta::Types::Array
16
16
  # - standard symbol types (:string, :integer, ...) — forwarded to AM
17
17
  def self.attribute(name, type = nil, array_of: nil, **options)
18
18
  if array_of
19
19
  element = element_type_for(array_of)
20
- type = Acta::ArrayType.new(element)
20
+ type = Acta::Types::Array.new(element)
21
21
  elsif type.is_a?(Class)
22
- type = Acta::ModelType.new(type)
22
+ type = Acta::Types::Model.new(type)
23
23
  end
24
24
 
25
25
  if type.nil?
@@ -31,7 +31,7 @@ module Acta
31
31
 
32
32
  def self.element_type_for(target)
33
33
  case target
34
- when Class then Acta::ModelType.new(target)
34
+ when Class then Acta::Types::Model.new(target)
35
35
  when Symbol then ActiveModel::Type.lookup(target)
36
36
  else target
37
37
  end
data/lib/acta/reactor.rb CHANGED
@@ -10,6 +10,28 @@ module Acta
10
10
  def sync?
11
11
  @sync == true
12
12
  end
13
+
14
+ # Declares the ActiveJob queue name to enqueue this reactor's job on.
15
+ # Read by Acta's dispatcher when the reactor is async (the default);
16
+ # ignored for `sync!` reactors. With no per-class declaration, the
17
+ # global `Acta.reactor_queue` setting applies; if that's also unset,
18
+ # ActiveJob's `:default` queue is used.
19
+ #
20
+ # class WelcomeEmailReactor < Acta::Reactor
21
+ # queue_as :fast
22
+ # on UserSignedUp do |event|
23
+ # UserMailer.welcome(event.user_id).deliver_later
24
+ # end
25
+ # end
26
+ def queue_as(name)
27
+ @queue_name = name
28
+ end
29
+
30
+ def queue_name
31
+ return @queue_name if defined?(@queue_name)
32
+
33
+ Acta.reactor_queue
34
+ end
13
35
  end
14
36
  end
15
37
  end
data/lib/acta/testing.rb CHANGED
@@ -6,6 +6,26 @@ module Acta
6
6
  module Testing
7
7
  DEFAULT_ACTOR_ATTRIBUTES = { type: "system", id: "rspec", source: "test" }.freeze
8
8
 
9
+ # Wraps writes to `acta_managed!` AR models so the projection-write guard
10
+ # accepts them. Useful for fixtures, factories, and ad-hoc setup that
11
+ # needs to bypass the command + event + projection chain.
12
+ #
13
+ # # spec/rails_helper.rb
14
+ # require "acta/testing"
15
+ # RSpec.configure do |config|
16
+ # Acta::Testing.projection_writes_helper!(config)
17
+ # end
18
+ #
19
+ # # in any spec:
20
+ # with_projection_writes do
21
+ # Trail.create!(name: "Crank It Up", zone: zone)
22
+ # end
23
+ module ProjectionWritesHelpers
24
+ def with_projection_writes(&block)
25
+ Acta::Projection.applying!(&block)
26
+ end
27
+ end
28
+
9
29
  module_function
10
30
 
11
31
  # Runs the given block with ActiveJob's :inline adapter, so async
@@ -46,5 +66,18 @@ module Acta
46
66
  Acta::Current.reset
47
67
  end
48
68
  end
69
+
70
+ # Includes `with_projection_writes` into every RSpec example. The helper
71
+ # forwards to `Acta::Projection.applying!`, so blocks under it pass the
72
+ # `acta_managed!` write guard. See ProjectionWritesHelpers.
73
+ #
74
+ # # spec/rails_helper.rb
75
+ # require "acta/testing"
76
+ # RSpec.configure do |config|
77
+ # Acta::Testing.projection_writes_helper!(config)
78
+ # end
79
+ def projection_writes_helper!(config)
80
+ config.include(ProjectionWritesHelpers)
81
+ end
49
82
  end
50
83
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module Acta
6
+ module Types
7
+ # Wraps any other Acta type as a list-of-element type. Used internally
8
+ # by `attribute :foo, array_of: Class` (or `array_of: :symbol`); not
9
+ # constructed directly by consumers.
10
+ class Array < ActiveModel::Type::Value
11
+ def initialize(element_type)
12
+ super()
13
+ @element_type = element_type
14
+ end
15
+
16
+ def cast(value)
17
+ return nil if value.nil?
18
+
19
+ Kernel.Array(value).map { |el| @element_type.cast(el) }
20
+ end
21
+
22
+ def serialize(value)
23
+ return nil if value.nil?
24
+
25
+ value.map { |el| @element_type.serialize(el) }
26
+ end
27
+
28
+ def deserialize(value)
29
+ return nil if value.nil?
30
+
31
+ value.map { |el| @element_type.deserialize(el) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module Acta
6
+ module Types
7
+ # Wraps an Acta::Model subclass (or any class with `to_acta_hash` /
8
+ # `from_acta_hash`, e.g. AR classes that include Acta::Serializable)
9
+ # so it can be used as an `attribute` type on another Acta::Model.
10
+ # The wrapping is automatic — `attribute :location, GeoPoint` invokes
11
+ # this internally; consumers don't construct it directly.
12
+ class Model < ActiveModel::Type::Value
13
+ def initialize(wrapped_class)
14
+ super()
15
+ @wrapped_class = wrapped_class
16
+ end
17
+
18
+ def cast(value)
19
+ case value
20
+ when nil then nil
21
+ when @wrapped_class then value
22
+ when Hash then @wrapped_class.from_acta_hash(value)
23
+ else
24
+ raise ArgumentError, "Cannot cast #{value.class} (#{value.inspect}) to #{@wrapped_class}"
25
+ end
26
+ end
27
+
28
+ def serialize(value)
29
+ return nil if value.nil?
30
+
31
+ value.to_acta_hash
32
+ end
33
+
34
+ def deserialize(value)
35
+ cast(value)
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/acta/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Acta
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/acta.rb CHANGED
@@ -26,6 +26,18 @@ ActiveSupport.on_load(:active_record) do
26
26
  end
27
27
 
28
28
  module Acta
29
+ # Global default for `Acta::Reactor` ActiveJob queue. Per-class
30
+ # `queue_as :foo` declarations on a Reactor override this. Apps with
31
+ # queue priority discipline typically set this to e.g. `:fast` in an
32
+ # initializer; left nil, ActiveJob's `:default` queue is used.
33
+ class << self
34
+ attr_writer :reactor_queue
35
+ end
36
+
37
+ def self.reactor_queue
38
+ @reactor_queue
39
+ end
40
+
29
41
  def self.adapter
30
42
  @adapter ||= Adapters.for(Record.connection)
31
43
  end
@@ -117,7 +129,10 @@ module Acta
117
129
  event:,
118
130
  reactor_class: registration[:handler_class]
119
131
  ) do
120
- ReactorJob.perform_later(
132
+ job = ReactorJob
133
+ queue = registration[:handler_class].queue_name
134
+ job = job.set(queue: queue) if queue
135
+ job.perform_later(
121
136
  event_uuid: event.uuid,
122
137
  reactor_class: registration[:handler_class].name,
123
138
  event_class: event.class.name
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/generators"
4
- require "rails/generators/migration"
5
4
  require "rails/generators/active_record"
5
+ require "rails/generators/active_record/migration"
6
6
 
7
7
  module Acta
8
8
  module Generators
9
9
  class InstallGenerator < Rails::Generators::Base
10
- include Rails::Generators::Migration
10
+ include ActiveRecord::Generators::Migration
11
11
 
12
12
  source_root File.expand_path("templates", __dir__)
13
13
 
14
- def self.next_migration_number(path)
15
- ActiveRecord::Generators::Base.next_migration_number(path)
16
- end
14
+ class_option :database, type: :string, aliases: %i[--db],
15
+ desc: "The database for the events migration. By default, the current environment's primary database is used."
17
16
 
18
17
  def create_migration_file
19
- migration_template "create_acta_events.rb.tt", "db/migrate/create_acta_events.rb"
18
+ migration_template "create_acta_events.rb.tt",
19
+ File.join(db_migrate_path, "create_acta_events.rb")
20
20
  end
21
21
  end
22
22
  end