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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +228 -33
- data/RELEASING.md +107 -0
- data/docs/README.md +31 -0
- data/docs/event_driven_pub_sub.md +258 -0
- data/gemfiles/rails_7_2.gemfile +20 -0
- data/gemfiles/rails_8_0.gemfile +20 -0
- data/gemfiles/rails_8_1.gemfile +20 -0
- data/lib/acta/model.rb +7 -7
- data/lib/acta/reactor.rb +22 -0
- data/lib/acta/testing.rb +33 -0
- data/lib/acta/types/array.rb +35 -0
- data/lib/acta/types/model.rb +39 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +16 -1
- data/lib/generators/acta/install/install_generator.rb +6 -6
- metadata +21 -16
- data/PLAN.md +0 -158
- data/lib/acta/array_type.rb +0 -30
- data/lib/acta/model_type.rb +0 -32
|
@@ -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 "
|
|
6
|
-
require_relative "
|
|
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
|
|
15
|
-
# - array_of: Class or array_of: :symbol — wrapped in
|
|
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::
|
|
20
|
+
type = Acta::Types::Array.new(element)
|
|
21
21
|
elsif type.is_a?(Class)
|
|
22
|
-
type = Acta::
|
|
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::
|
|
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
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
|
|
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
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
11
|
|
|
12
12
|
source_root File.expand_path("templates", __dir__)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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",
|
|
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
|