acta 0.2.0 → 0.4.0.alpha.1
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 +116 -0
- data/README.md +229 -33
- data/RELEASING.md +107 -0
- data/docs/README.md +32 -0
- data/docs/event_driven_pub_sub.md +258 -0
- data/docs/upcasters.md +303 -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/errors.rb +38 -0
- data/lib/acta/events_query.rb +51 -4
- data/lib/acta/model.rb +7 -7
- data/lib/acta/reactor.rb +22 -0
- data/lib/acta/record.rb +49 -1
- data/lib/acta/testing/dsl.rb +53 -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/upcaster.rb +239 -0
- data/lib/acta/version.rb +1 -1
- data/lib/acta.rb +37 -4
- data/lib/generators/acta/install/install_generator.rb +6 -6
- metadata +23 -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.
|
data/docs/upcasters.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# Schema evolution with upcasters
|
|
2
|
+
|
|
3
|
+
Acta records are immutable: once an event lands in the events table,
|
|
4
|
+
nothing edits it. That's the property the audit log relies on. But
|
|
5
|
+
app schemas evolve — a new attribute appears, a semantic shifts, an
|
|
6
|
+
event type gets renamed. The straightforward options are unappealing:
|
|
7
|
+
|
|
8
|
+
- **Mutate history** — rewrite the events table. Breaks immutability
|
|
9
|
+
and any external consumer of the log.
|
|
10
|
+
- **Snapshot the boundary** — preserve projections at the cut and
|
|
11
|
+
declare replay-from-zero unsupported. Loses event sourcing's core
|
|
12
|
+
promise.
|
|
13
|
+
- **Accept that replay-from-zero is broken** — keep emitting new
|
|
14
|
+
shapes but admit `Acta.rebuild!` can't produce the current
|
|
15
|
+
projection from scratch. Corrosive over time.
|
|
16
|
+
|
|
17
|
+
**Upcasters** are the standard event-sourcing answer: at replay
|
|
18
|
+
time, transform old-shape records into new-shape records in memory,
|
|
19
|
+
before projections see them. The stored rows are never touched. The
|
|
20
|
+
transformation logic lives in code, where it's tested and audited.
|
|
21
|
+
|
|
22
|
+
## When to reach for an upcaster
|
|
23
|
+
|
|
24
|
+
- You renamed an event type (`ItemCreated` → `WorkspaceCreated`) and
|
|
25
|
+
want old records to apply to the new projection.
|
|
26
|
+
- You added a required field to an event class and old records lack
|
|
27
|
+
it; you can derive a default at replay time.
|
|
28
|
+
- You're dropping an obsolete event type and want pre-deprecation
|
|
29
|
+
records to be skipped on replay.
|
|
30
|
+
- You're splitting one logical event into several finer-grained ones
|
|
31
|
+
(a 1-to-many fan-out at replay).
|
|
32
|
+
|
|
33
|
+
If your schema change is purely additive *and* every site that reads
|
|
34
|
+
the field tolerates `nil`, you can probably skip upcasters: bump the
|
|
35
|
+
event class to add the attribute, leave `event_version` alone, and
|
|
36
|
+
projections cope with missing-field cases inline. Reach for an
|
|
37
|
+
upcaster when "tolerate missing field" turns into more conditional
|
|
38
|
+
logic than the transform would be.
|
|
39
|
+
|
|
40
|
+
## The shape of an upcaster
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# app/upcasters/workspace_migration_upcasters.rb
|
|
44
|
+
module WorkspaceMigrationUpcasters
|
|
45
|
+
include Acta::Upcaster
|
|
46
|
+
|
|
47
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, context|
|
|
48
|
+
payload = event.payload
|
|
49
|
+
|
|
50
|
+
if payload["item_type"] == "goal"
|
|
51
|
+
# A v1 goal becomes a v2 workspace. Record the mapping so
|
|
52
|
+
# descendant items can resolve their workspace_id below.
|
|
53
|
+
context[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
|
|
54
|
+
|
|
55
|
+
event.upcast_to(
|
|
56
|
+
type: "WorkspaceCreated",
|
|
57
|
+
payload: {
|
|
58
|
+
"workspace_id" => payload["item_id"],
|
|
59
|
+
"title" => payload["title"]
|
|
60
|
+
},
|
|
61
|
+
schema_version: 2
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
workspace_id =
|
|
65
|
+
context[:goal_to_workspace][payload["parent_id"]] ||
|
|
66
|
+
context[:item_to_workspace][payload["parent_id"]]
|
|
67
|
+
|
|
68
|
+
if workspace_id.nil?
|
|
69
|
+
context.fail_replay!(
|
|
70
|
+
"Unmappable item #{payload['item_id']}: no goal ancestor"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
context[:item_to_workspace][payload["item_id"]] = workspace_id
|
|
75
|
+
|
|
76
|
+
event.upcast_to(
|
|
77
|
+
payload: payload.merge("workspace_id" => workspace_id),
|
|
78
|
+
schema_version: 2
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Register it once at boot:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# config/initializers/acta_upcasters.rb
|
|
89
|
+
Acta.register_upcaster(WorkspaceMigrationUpcasters)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then bump the new emit path so freshly emitted events carry
|
|
93
|
+
`event_version: 2`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class ItemCreated < Acta::Event
|
|
97
|
+
def self.event_version = 2
|
|
98
|
+
attribute :item_id, :string
|
|
99
|
+
attribute :workspace_id, :string
|
|
100
|
+
# ...
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
That's the whole feature surface. New writes are at v2; old reads
|
|
105
|
+
get upcasted to v2 before they hit projections.
|
|
106
|
+
|
|
107
|
+
## What blocks can return
|
|
108
|
+
|
|
109
|
+
Inside an `upcasts` block, the return value controls what the
|
|
110
|
+
pipeline does next:
|
|
111
|
+
|
|
112
|
+
| Return value | Effect |
|
|
113
|
+
| ------------------------------------- | ------------------------------------------------ |
|
|
114
|
+
| `event.upcast_to(...)` | Continue chaining at the new (type, version) |
|
|
115
|
+
| Array of `event.upcast_to(...)` | Fan-out: each branch chains independently |
|
|
116
|
+
| `nil` or `[]` | Drop the record from this replay |
|
|
117
|
+
| `context.fail_replay!("reason")` | Halt with `Acta::ReplayHaltedByUpcaster` |
|
|
118
|
+
|
|
119
|
+
If you need to leave a record alone at the current version — e.g. a
|
|
120
|
+
boundary-marker event that's already in its final shape — use the
|
|
121
|
+
NO_OP sentinel:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
upcasts "GoalPromotedToWorkspace", from: 2, to: 2, &Acta::Upcaster::NO_OP
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Stateless vs stateful upcasters
|
|
128
|
+
|
|
129
|
+
Upcasters come in two flavors and the distinction matters for which
|
|
130
|
+
read surfaces will produce correct output.
|
|
131
|
+
|
|
132
|
+
**Stateless** — the transform depends only on the record itself.
|
|
133
|
+
Adding a default for a new field, renaming a key in the payload,
|
|
134
|
+
or unconditionally bumping the event type all qualify. The
|
|
135
|
+
`context` argument is ignored.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, _ctx|
|
|
139
|
+
event.upcast_to(
|
|
140
|
+
payload: event.payload.merge("workspace_id" => "default"),
|
|
141
|
+
schema_version: 2
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Stateful** — the transform depends on context populated by an
|
|
147
|
+
earlier event in the same replay. Resolving a descendant's
|
|
148
|
+
`workspace_id` from a goal seen earlier in the stream is the
|
|
149
|
+
canonical example.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
upcasts "ItemCreated", from: 1, to: 2 do |event, ctx|
|
|
153
|
+
payload = event.payload
|
|
154
|
+
if payload["item_type"] == "goal"
|
|
155
|
+
ctx[:goal_to_workspace][payload["item_id"]] = payload["item_id"]
|
|
156
|
+
event.upcast_to(type: "WorkspaceCreated", ...)
|
|
157
|
+
else
|
|
158
|
+
workspace_id = ctx[:goal_to_workspace][payload["parent_id"]]
|
|
159
|
+
event.upcast_to(payload: payload.merge("workspace_id" => workspace_id), schema_version: 2)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Stateful upcasters require **global insertion order**, which is
|
|
165
|
+
exactly what `Acta.rebuild!` (and `Acta.events.all` / `#each`)
|
|
166
|
+
provides. They will silently produce incomplete output on read
|
|
167
|
+
surfaces that can't supply that order — see the next section.
|
|
168
|
+
|
|
169
|
+
## Context semantics across read surfaces
|
|
170
|
+
|
|
171
|
+
Different read paths give upcasters different views of the world.
|
|
172
|
+
Stateless upcasters are unaffected by any of this; stateful
|
|
173
|
+
upcasters need to know.
|
|
174
|
+
|
|
175
|
+
| Read surface | Context lifetime | Safe for stateful upcasters? |
|
|
176
|
+
| ------------------------------------- | -------------------------------- | ---------------------------- |
|
|
177
|
+
| `Acta.rebuild!` | One shared, full insertion order | Yes |
|
|
178
|
+
| `Acta.events.all` / `#each` | One shared, full insertion order | Yes |
|
|
179
|
+
| `Acta.events.find_by_uuid(uuid)` | Fresh per call | No — incomplete resolution |
|
|
180
|
+
| `Acta.events.first` / `.last` | Fresh per call | No — incomplete resolution |
|
|
181
|
+
| `Acta.events.for_stream(...)#all` | Shared, but stream-ordered | Usually no — wrong order |
|
|
182
|
+
| `Acta::ReactorJob#perform` | Fresh, single record | No — incomplete resolution |
|
|
183
|
+
| Web admin (`Acta::Web::EventsController`) | N/A — shows raw stored rows | N/A |
|
|
184
|
+
|
|
185
|
+
The pattern: any time you hand the pipeline a full ordered stream,
|
|
186
|
+
stateful upcasters work. Any time you hand it one record (or a
|
|
187
|
+
stream-reordered subset), they can't reconstruct the state they
|
|
188
|
+
need.
|
|
189
|
+
|
|
190
|
+
### Implication for stateful migrations
|
|
191
|
+
|
|
192
|
+
A stateful migration is fundamentally a `rebuild!`-shaped operation.
|
|
193
|
+
The cutover playbook is:
|
|
194
|
+
|
|
195
|
+
1. Deploy code that emits at the new `event_version` and includes
|
|
196
|
+
the upcasters.
|
|
197
|
+
2. Drain the reactor queue. Jobs enqueued before the deploy will
|
|
198
|
+
re-hydrate their events through the upcaster pipeline with a
|
|
199
|
+
fresh context — fine for stateless upcasters, possibly
|
|
200
|
+
incomplete for stateful ones.
|
|
201
|
+
3. Run `Acta.rebuild!` to regenerate projections from the full
|
|
202
|
+
ordered log under the new schema.
|
|
203
|
+
4. Flip reads.
|
|
204
|
+
|
|
205
|
+
Apps that need stateful read-time resolution outside `rebuild!`
|
|
206
|
+
should consider whether the resolved field belongs in the
|
|
207
|
+
projection rather than in the upcaster — projections are the
|
|
208
|
+
durable, queryable view, and once `rebuild!` has run, projections
|
|
209
|
+
hold the post-upcast state without needing the pipeline at read
|
|
210
|
+
time.
|
|
211
|
+
|
|
212
|
+
## Chaining across N versions
|
|
213
|
+
|
|
214
|
+
Upcasters can be declared on a single event type across many
|
|
215
|
+
versions; the pipeline walks them in order:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
module SuccessiveBumps
|
|
219
|
+
include Acta::Upcaster
|
|
220
|
+
|
|
221
|
+
upcasts "ItemCreated", from: 1, to: 2 do |e, _|
|
|
222
|
+
e.upcast_to(payload: e.payload.merge("workspace_id" => "?"), schema_version: 2)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
upcasts "ItemCreated", from: 2, to: 3 do |e, _|
|
|
226
|
+
e.upcast_to(payload: e.payload.except("legacy_kind"), schema_version: 3)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
A v1 record passes through both transforms in sequence. A v2 record
|
|
232
|
+
(emitted between the two migrations) picks up the second transform
|
|
233
|
+
only. Events emitted by current v3 code pass through identity.
|
|
234
|
+
|
|
235
|
+
## Testing upcasters
|
|
236
|
+
|
|
237
|
+
Two helpers live in `Acta::Testing::DSL`:
|
|
238
|
+
|
|
239
|
+
- `acta_seed_event(type:, payload:, event_version: 1, ...)` —
|
|
240
|
+
inserts an event row directly, bypassing `Acta.emit` (which always
|
|
241
|
+
stamps the *current* code's `event_version`).
|
|
242
|
+
- `acta_replay(events:, upcasters: [])` — registers the supplied
|
|
243
|
+
upcasters, seeds events, and runs `Acta.rebuild!`.
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
RSpec.describe "Workspaces migration" do
|
|
247
|
+
include Acta::Testing::DSL
|
|
248
|
+
|
|
249
|
+
it "promotes goals to workspaces and rewires descendants" do
|
|
250
|
+
acta_replay(
|
|
251
|
+
upcasters: [ WorkspaceMigrationUpcasters ],
|
|
252
|
+
events: [
|
|
253
|
+
{ type: "ItemCreated", event_version: 1,
|
|
254
|
+
payload: { "item_id" => "g_1", "item_type" => "goal", "title" => "Q3" } },
|
|
255
|
+
{ type: "ItemCreated", event_version: 1,
|
|
256
|
+
payload: { "item_id" => "i_2", "parent_id" => "g_1", "title" => "Plan" } }
|
|
257
|
+
]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
expect(Workspace.pluck(:id)).to eq([ "g_1" ])
|
|
261
|
+
expect(Item.find("i_2").workspace_id).to eq("g_1")
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The existing `ensure_replay_deterministic` matcher implicitly
|
|
267
|
+
exercises the upcaster pipeline twice — impure upcasters (state
|
|
268
|
+
leaking outside the per-replay context) surface as a snapshot diff
|
|
269
|
+
on the second pass.
|
|
270
|
+
|
|
271
|
+
## What upcasters intentionally do *not* do
|
|
272
|
+
|
|
273
|
+
- **They don't rewrite the event store.** Stored rows are immutable;
|
|
274
|
+
transforms exist only in memory during a replay pass.
|
|
275
|
+
- **They don't run on the live emit path.** `Acta.emit` stamps the
|
|
276
|
+
current code's `event_version` and dispatches the in-memory event
|
|
277
|
+
directly — no read round-trip, no upcaster pass. Live writes are
|
|
278
|
+
always at the latest version.
|
|
279
|
+
- **They're not a migration framework.** The decision to bump
|
|
280
|
+
`event_version` and write an upcaster is the schema migration. The
|
|
281
|
+
upcaster's job is just replay correctness.
|
|
282
|
+
- **They're not cross-tenant.** Each tenant's events table replays
|
|
283
|
+
independently; upcaster context is per-replay-pass, per-tenant.
|
|
284
|
+
|
|
285
|
+
## Edge cases worth knowing about
|
|
286
|
+
|
|
287
|
+
- **Future-version records.** If a replay sees an event whose stored
|
|
288
|
+
`event_version` exceeds the highest `to` any registered upcaster
|
|
289
|
+
knows how to reach for that type, the pipeline raises
|
|
290
|
+
`Acta::FutureSchemaVersion`. Typically: an older deployment is
|
|
291
|
+
replaying events emitted by a newer one. Halting is the safe call.
|
|
292
|
+
- **Type renames remove the need to keep old classes around.**
|
|
293
|
+
Upcasters operate on raw records pre-hydration, so the original
|
|
294
|
+
`ItemCreated` constant can be deleted from the codebase the moment
|
|
295
|
+
its upcaster renames the type. Hydration only ever happens for
|
|
296
|
+
classes the upcaster pipeline produces.
|
|
297
|
+
- **1-to-many composes with chaining.** Each record an upcaster
|
|
298
|
+
fans out into walks the version ladder independently — including
|
|
299
|
+
through more 1-to-many transforms if you have them.
|
|
300
|
+
- **Conflicting registrations raise at boot.** Two upcaster classes
|
|
301
|
+
that claim the same `(event_type, from)` pair surface as
|
|
302
|
+
`Acta::UpcasterRegistryError` the moment the second one registers.
|
|
303
|
+
Pick an owner.
|
|
@@ -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/errors.rb
CHANGED
|
@@ -99,4 +99,42 @@ module Acta
|
|
|
99
99
|
)
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Raised by `context.fail_replay!(reason)` inside an upcaster block. Halts
|
|
104
|
+
# replay so the operator can investigate rather than land a partial,
|
|
105
|
+
# possibly-corrupt projection.
|
|
106
|
+
class ReplayHaltedByUpcaster < Error
|
|
107
|
+
attr_reader :record, :reason
|
|
108
|
+
|
|
109
|
+
def initialize(record:, reason:)
|
|
110
|
+
@record = record
|
|
111
|
+
@reason = reason
|
|
112
|
+
super(
|
|
113
|
+
"Upcaster halted replay on event id=#{record.id} uuid=#{record.uuid} " \
|
|
114
|
+
"(#{record.event_type} v#{record.event_version}): #{reason}"
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Raised at registration time when an upcaster set is malformed — e.g.
|
|
120
|
+
# `from` >= `to`, or two upcasters claim the same (event_type, from).
|
|
121
|
+
class UpcasterRegistryError < Error; end
|
|
122
|
+
|
|
123
|
+
# Raised when a record's stored event_version exceeds anything the
|
|
124
|
+
# currently-loaded upcaster registry knows how to reach. Typically means
|
|
125
|
+
# an older deployment is replaying events emitted by a newer one.
|
|
126
|
+
class FutureSchemaVersion < Error
|
|
127
|
+
attr_reader :record, :latest_known_version
|
|
128
|
+
|
|
129
|
+
def initialize(record:, latest_known_version:)
|
|
130
|
+
@record = record
|
|
131
|
+
@latest_known_version = latest_known_version
|
|
132
|
+
super(
|
|
133
|
+
"Event id=#{record.id} uuid=#{record.uuid} (#{record.event_type}) is at " \
|
|
134
|
+
"event_version #{record.event_version}, but the loaded upcaster registry " \
|
|
135
|
+
"only knows up to v#{latest_known_version}. Likely an older deployment " \
|
|
136
|
+
"replaying events emitted by a newer one."
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
102
140
|
end
|